@@ -105,6 +105,127 @@ export default function PassportPage() {
105105 }
106106 }
107107
108+ /**
109+ * A4 credit passport. Mirrors the badge PDF in /v3 so a partner receives the
110+ * same artefact from either surface — a portable document, not a text dump.
111+ */
112+ async function downloadPassportPdf ( ) {
113+ if ( ! profile ) return ;
114+ const { jsPDF } = await import ( "jspdf" ) ;
115+ const doc = new jsPDF ( { unit : "pt" , format : "a4" } ) ;
116+ const addr = pubkey . trim ( ) ;
117+ const tier = profile . score . badgeType ;
118+ const tierColors : Record < string , [ number , number , number ] > = {
119+ Gold : [ 212 , 175 , 55 ] ,
120+ Silver : [ 192 , 192 , 192 ] ,
121+ Bronze : [ 205 , 127 , 50 ] ,
122+ None : [ 80 , 80 , 80 ] ,
123+ } ;
124+ const [ r , g , b ] = tierColors [ tier ] ?? [ 34 , 197 , 94 ] ;
125+
126+ doc . setFillColor ( 5 , 5 , 5 ) ;
127+ doc . rect ( 0 , 0 , 595 , 842 , "F" ) ;
128+ doc . setFillColor ( r , g , b ) ;
129+ doc . rect ( 0 , 0 , 595 , 8 , "F" ) ;
130+
131+ doc . setTextColor ( 34 , 197 , 94 ) ;
132+ doc . setFont ( "helvetica" , "bold" ) ;
133+ doc . setFontSize ( 28 ) ;
134+ doc . text ( "VIGENTE PROTOCOL" , 40 , 80 ) ;
135+ doc . setTextColor ( 180 , 180 , 180 ) ;
136+ doc . setFont ( "helvetica" , "normal" ) ;
137+ doc . setFontSize ( 11 ) ;
138+ doc . text ( "credit passport · Stellar Soroban testnet" , 40 , 100 ) ;
139+
140+ doc . setTextColor ( 255 , 255 , 255 ) ;
141+ doc . setFont ( "helvetica" , "bold" ) ;
142+ doc . setFontSize ( 64 ) ;
143+ doc . text ( tier . toUpperCase ( ) , 40 , 200 ) ;
144+ doc . setFontSize ( 14 ) ;
145+ doc . setFont ( "helvetica" , "normal" ) ;
146+ doc . setTextColor ( r , g , b ) ;
147+ doc . text ( `tier · score ${ profile . score . totalScore } / 100` , 40 , 225 ) ;
148+
149+ const row = ( label : string , value : string , y : number ) => {
150+ doc . setTextColor ( 130 , 130 , 130 ) ;
151+ doc . setFontSize ( 9 ) ;
152+ doc . text ( label . toUpperCase ( ) , 40 , y ) ;
153+ doc . setTextColor ( 255 , 255 , 255 ) ;
154+ doc . setFontSize ( 12 ) ;
155+ doc . text ( value , 40 , y + 16 ) ;
156+ } ;
157+
158+ let y = 285 ;
159+ row ( "account" , addr , y ) ; y += 46 ;
160+ row ( "account age" , `${ profile . features . account_age_days } days` , y ) ; y += 46 ;
161+ row ( "operations evaluated" , String ( profile . features . ops_evaluated ) , y ) ; y += 46 ;
162+ row (
163+ "score breakdown" ,
164+ `volume ${ profile . score . breakdown . volumePoints } · consistency ${ profile . score . breakdown . consistencyPoints } · frequency ${ profile . score . breakdown . frequencyPoints } ` ,
165+ y ,
166+ ) ;
167+ y += 46 ;
168+ row (
169+ "on-chain badge" ,
170+ badge
171+ ? `score ${ badge . score } ${ badge . isDefaulted ? " · DEFAULTED" : "" } `
172+ : "not minted" ,
173+ y ,
174+ ) ;
175+ y += 46 ;
176+ row ( "issued" , new Date ( ) . toISOString ( ) . replace ( "T" , " " ) . slice ( 0 , 19 ) + " UTC" , y ) ;
177+
178+ // The limits below are what makes this a credit document rather than a score
179+ // sheet, so they are stated with their source and their caveat.
180+ y += 60 ;
181+ doc . setDrawColor ( 40 , 40 , 40 ) ;
182+ doc . line ( 40 , y , 555 , y ) ;
183+ y += 24 ;
184+ doc . setTextColor ( 130 , 130 , 130 ) ;
185+ doc . setFontSize ( 9 ) ;
186+ doc . text ( "HOW THIS IS USED" , 40 , y ) ;
187+ y += 18 ;
188+ doc . setTextColor ( 200 , 200 , 200 ) ;
189+ doc . setFontSize ( 10 ) ;
190+ for ( const line of [
191+ "A margin controller reads the ON-CHAIN badge score and sets the borrower's" ,
192+ "loan-to-value from it. On the 0-1000 badge scale: >=800 borrows at 85% of" ,
193+ "collateral, >=550 at 75%, >=300 at 60%. Below 300 the protocol does not lend." ,
194+ "" ,
195+ "Note the two scales. The profile score above is 0-100 and is the preview" ,
196+ "computed from public Stellar Horizon activity; the badge score is 0-1000 and is" ,
197+ "what the contract actually reads." ,
198+ "" ,
199+ "The score is computed off-chain and signed by 3 of 5 independent keys, verified" ,
200+ "on-chain at mint. The chain verifies the signatures, not the methodology - this" ,
201+ "document is an attestation, not a credit rating." ,
202+ ] ) {
203+ doc . text ( line , 40 , y ) ;
204+ y += 14 ;
205+ }
206+
207+ y += 20 ;
208+ doc . setTextColor ( 110 , 110 , 110 ) ;
209+ doc . setFontSize ( 9 ) ;
210+ doc . text ( "VERIFY" , 40 , y ) ;
211+ y += 16 ;
212+ doc . setTextColor ( 160 , 160 , 160 ) ;
213+ doc . setFontSize ( 8 ) ;
214+ doc . text ( "badge CDLLO7QEPX2FGOF4VVEV7ISD7PL6FGEBO4N7XMGSIPVULOW43DZRHWVD" , 40 , y ) ;
215+ y += 12 ;
216+ doc . text ( "controller CCZNOV65BYYMJP35CJDBRSUE5S6HRAW4R2MCB7LY4SVOXOHJKWK7OCLJ" , 40 , y ) ;
217+ y += 12 ;
218+ doc . text ( "pool CDYUHA3TPDCAP5FAJMVPMFDW35ZCPSUV2ND2K2G5EB3QYMUDERKPHNUI" , 40 , y ) ;
219+ y += 12 ;
220+ doc . text ( "github.com/zzzbedream/VIGENTE-PROJECT" , 40 , y ) ;
221+
222+ doc . setTextColor ( 90 , 90 , 90 ) ;
223+ doc . setFontSize ( 8 ) ;
224+ doc . text ( "Testnet. Not a credit report under any regulatory framework." , 40 , 812 ) ;
225+
226+ doc . save ( `vigente-passport-${ addr . slice ( 0 , 8 ) } .pdf` ) ;
227+ }
228+
108229 return (
109230 < main
110231 style = { { fontFamily : "var(--font-readex-pro), system-ui, sans-serif" } }
@@ -149,7 +270,18 @@ export default function PassportPage() {
149270 ) }
150271
151272 { profile && (
152- < PassportCard pubkey = { pubkey . trim ( ) } profile = { profile } badge = { badge } />
273+ < >
274+ < div className = "flex justify-end mb-3" >
275+ < button
276+ type = "button"
277+ onClick = { ( ) => void downloadPassportPdf ( ) }
278+ className = "text-xs border border-white/15 hover:border-[#22c55e]/60 hover:text-[#22c55e] rounded-md px-3 py-1.5 transition-colors"
279+ >
280+ download PDF
281+ </ button >
282+ </ div >
283+ < PassportCard pubkey = { pubkey . trim ( ) } profile = { profile } badge = { badge } />
284+ </ >
153285 ) }
154286 </ div >
155287 </ main >
0 commit comments