diff --git a/README.md b/README.md index 2796c64..8a87121 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm test npm run dev # http://localhost:3000 ``` -`npm test` runs the six self-check scripts in `src/lib/*.check.ts` with `tsx` (pinned in `package.json`). They need no network. All six passed on 2026-10-24. `issuance.check.ts` reconciles the committed snapshots. CI runs lint, the type check, `npm test` and `npm run build`. +`npm test` runs the seven self-check scripts in `src/lib/*.check.ts` with `tsx` (pinned in `package.json`). They need no network. `issuance.check.ts` reconciles the committed snapshots. CI runs lint, the type check, `npm test` and `npm run build`. To refresh the stablecoin snapshots (about 25 minutes on the free public RPC), then rebuild the study guide: @@ -58,6 +58,8 @@ npx tsx src/lib/issuance.check.ts npx tsx docs/textbook/build.ts ``` +The USDC share card (`docs/img/usdc-share-card.svg` and `.png`, 1024px wide, navy and red) is built from the same snapshot. On posting day run `npm run card:usdc-share:fresh`: it re-pulls the event logs, then renders the card with its data-as-of date. `npm run card:usdc-share` renders from the existing snapshot and refuses one that is not from today (`--allow-stale` overrides). + ## Repository layout ``` diff --git a/docs/img/usdc-share-card.png b/docs/img/usdc-share-card.png new file mode 100644 index 0000000..2dc3cff Binary files /dev/null and b/docs/img/usdc-share-card.png differ diff --git a/docs/img/usdc-share-card.svg b/docs/img/usdc-share-card.svg new file mode 100644 index 0000000..3e5e824 --- /dev/null +++ b/docs/img/usdc-share-card.svg @@ -0,0 +1,48 @@ + +USDC share of mint and redemption volume on Ethereum, data as of Oct 25, 2026 + +USDC is 85.0% of the $1,016.8B +minted and redeemed on Ethereum +Six assets, Jan 2023 to the week of Oct 12, 2026 + + +USDC 85.0% +Other five assets 15.0% +Asset +Mints + redemptions +Share + +USDC + +$864.8B +85.0% +USDT + +$108.8B +10.7% +PYUSD + +$36.5B +3.6% +BUIDL + +$4.3B +0.4% +OUSG + +$2.1B +0.2% +USDY + +$0.3B +0.0% + +Total +$1,016.8B +100.0% +USDC: $420.5B minted, $444.2B redeemed. +Mostly market-maker churn. Ethereum logs only. + +Data as of Oct 25, 2026 +github.com/RahilBhavan/Ondo + diff --git a/package.json b/package.json index a39a8f9..6717715 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "eslint .", - "test": "for f in src/lib/*.check.ts; do tsx \"$f\" || exit 1; done" + "test": "for f in src/lib/*.check.ts; do tsx \"$f\" || exit 1; done", + "card:usdc-share": "tsx scripts/usdc-share-card.ts", + "card:usdc-share:fresh": "tsx scripts/usdc-share-card.ts --refresh" }, "dependencies": { "next": "^16.4.2", diff --git a/scripts/usdc-share-card.ts b/scripts/usdc-share-card.ts new file mode 100644 index 0000000..3fee1f2 --- /dev/null +++ b/scripts/usdc-share-card.ts @@ -0,0 +1,39 @@ +/** + * Regenerates the USDC share card from the issuance snapshot: docs/img/usdc-share-card.svg and, when + * rsvg-convert is installed, a 1024px-wide PNG beside it. + * + * npm run card:usdc-share render from the snapshot (refuses a snapshot that is not from today) + * npm run card:usdc-share:fresh re-pull the event logs first (about 25 to 40 minutes), then render + * + * --allow-stale renders an older snapshot anyway; the card still shows its as-of date. + */ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { cardData, cardSvg, CARD_WIDTH } from '../src/lib/usdcShareCard'; + +const args = new Set(process.argv.slice(2)); + +if (args.has('--refresh')) { + execFileSync('node', ['scripts/backfill-issuance.mjs'], { stdio: 'inherit' }); + // The feed just changed on disk; render in a fresh process so the new snapshot is what gets imported. + execFileSync('npx', ['tsx', 'scripts/usdc-share-card.ts'], { stdio: 'inherit' }); + process.exit(0); +} + +const d = cardData(); +const today = new Date().toISOString().slice(0, 10); +if (d.asOf.slice(0, 10) !== today && !args.has('--allow-stale')) { + console.error(`Snapshot is from ${d.asOf.slice(0, 10)}, not ${today}. Run: npm run card:usdc-share:fresh (or pass --allow-stale).`); + process.exit(1); +} + +const dir = new URL('../docs/img/', import.meta.url); +mkdirSync(dir, { recursive: true }); +const svgPath = new URL('usdc-share-card.svg', dir); +writeFileSync(svgPath, cardSvg(d)); +console.log(`wrote ${svgPath.pathname} (data as of ${d.asOf}, through the week of ${d.lastWeek})`); + +const pngPath = new URL('usdc-share-card.png', dir); +const png = spawnSync('rsvg-convert', ['-w', String(CARD_WIDTH), '-o', pngPath.pathname, svgPath.pathname], { stdio: 'inherit' }); +if (png.error || png.status !== 0) console.warn('rsvg-convert not available or failed: SVG only. Convert it to PNG before posting.'); +else console.log(`wrote ${pngPath.pathname}`); diff --git a/src/data/issuance.json b/src/data/issuance.json index a02ef63..b1b0658 100644 --- a/src/data/issuance.json +++ b/src/data/issuance.json @@ -1,31 +1,31 @@ { - "asOf": "2026-10-24T20:24:48.539Z", + "asOf": "2026-10-25T19:06:58.998Z", "startBlock": 16308190, - "headBlock": 26264879, + "headBlock": 26271672, "audit": { - "USDC:mint": 2015024, - "USDC:redeem": 1158907, + "USDC:mint": 2017925, + "USDC:redeem": 1160746, "USDT:mint": 80, "USDT:redeem": 12, - "PYUSD:mint": 13948, - "PYUSD:redeem": 8426, + "PYUSD:mint": 13967, + "PYUSD:redeem": 8432, "BUIDL:mint": 13570, "BUIDL:redeem": 225, "OUSG:mint": 234, "OUSG:redeem": 304, "OUSG-legacy:mint": 173, "OUSG-legacy:redeem": 203, - "USDY:mint": 797, + "USDY:mint": 802, "USDY:redeem": 753 }, "usdcCounterparties": { "mint": { - "other": 424202977279.74664, - "cctp": 112530478614.17215 + "other": 424327937828.7949, + "cctp": 112593426868.90857 }, "redeem": { - "other": 448808775705.0318, - "cctp": 81673174270.20108 + "other": 448936775757.7956, + "cctp": 81783747493.26077 } }, "rows": [ @@ -8625,37 +8625,37 @@ "week": "2026-10-19", "asset": "PYUSD", "issuer": "PayPal (Paxos)", - "mintUsd": 305241219, - "redeemUsd": 221200337, + "mintUsd": 305462079, + "redeemUsd": 222100773, "bridgeMintUsd": 0, "bridgeRedeemUsd": 0, - "mintCount": 353, - "redeemCount": 128, - "wallets": 304 + "mintCount": 372, + "redeemCount": 134, + "wallets": 325 }, { "week": "2026-10-19", "asset": "USDC", "issuer": "Circle", - "mintUsd": 4908418272, - "redeemUsd": 5397256618, - "bridgeMintUsd": 1248505307, - "bridgeRedeemUsd": 815652278, - "mintCount": 29369, - "redeemCount": 14505, - "wallets": 7503 + "mintUsd": 5096327076, + "redeemUsd": 5635829894, + "bridgeMintUsd": 1311453562, + "bridgeRedeemUsd": 926225501, + "mintCount": 32270, + "redeemCount": 16344, + "wallets": 8284 }, { "week": "2026-10-19", "asset": "USDY", "issuer": "Ondo", - "mintUsd": 17211119, + "mintUsd": 17284764, "redeemUsd": 2330443, "bridgeMintUsd": 0, "bridgeRedeemUsd": 0, - "mintCount": 25, + "mintCount": 30, "redeemCount": 19, - "wallets": 17 + "wallets": 19 } ] } \ No newline at end of file diff --git a/src/lib/usdcShareCard.check.ts b/src/lib/usdcShareCard.check.ts new file mode 100644 index 0000000..765107f --- /dev/null +++ b/src/lib/usdcShareCard.check.ts @@ -0,0 +1,38 @@ +/** + * Self-check for the USDC share card. Run: npx tsx src/lib/usdcShareCard.check.ts + * Pins the review's requirements: numbers from the snapshot, one number format, navy and red only, text at least 26px. + */ +import assert from 'node:assert/strict'; +import { grossIssuerVolume, totals } from './issuance'; +import { cardData, cardSvg, fmtDate, MIN_FONT_PX, NAVY, RED } from './usdcShareCard'; + +const d = cardData(); +const svg = cardSvg(d); + +// 1. The card's numbers are the issuance snapshot's. +assert.equal(d.total, grossIssuerVolume()); +const usdc = totals().find((t) => t.asset.id === 'USDC')!; +assert.equal(d.rows.find((r) => r.asset === 'USDC')!.gross, usdc.gross); +assert.ok(Math.abs(d.rows.reduce((a, r) => a + r.share, 0) - 1) < 1e-9); + +// 2. An as-of date is on the card, and it is the snapshot's. +assert.ok(svg.includes(`Data as of ${fmtDate(d.asOf)}`)); + +// 3. One number format: every dollar figure is $B with one decimal, every share is % with one decimal. +const texts = [...svg.matchAll(/]*>([^<]*)<\/text>/g)].map((m) => m[1]); +for (const t of texts) { + for (const m of t.matchAll(/\$[\d,.]+[A-Za-z]*/g)) assert.match(m[0], /^\$\d{1,3}(,\d{3})*\.\dB$/, `dollar format: ${m[0]}`); + for (const m of t.matchAll(/[\d.]+%/g)) assert.match(m[0], /^\d{1,3}\.\d%$/, `percent format: ${m[0]}`); +} +assert.ok(!/\$\d[\d,.]*T\b/.test(svg), 'no $T figures mixed with $B'); + +// 4. Navy, red and the white canvas only. +const colors = new Set([...svg.matchAll(/(?:fill|stroke)="(#[0-9a-fA-F]{3,8})"/g)].map((m) => m[1].toLowerCase())); +assert.deepEqual([...colors].sort(), [NAVY, RED, '#ffffff'].sort()); +assert.ok(!/opacity|gradient/i.test(svg), 'no tints'); + +// 5. Smallest text is MIN_FONT_PX at the 1024px canvas, and the SVG is rendered 1:1 at that width. +assert.match(svg, /width="1024"/); +const sizes = [...svg.matchAll(/font-size="(\d+(?:\.\d+)?)"/g)].map((m) => Number(m[1])); +assert.ok(sizes.length >= texts.length && Math.min(...sizes) >= MIN_FONT_PX, `smallest font ${Math.min(...sizes)}px`); +console.log(`usdcShareCard: ${texts.length} text items, smallest ${Math.min(...sizes)}px, colors ${[...colors].join(' ')}`); diff --git a/src/lib/usdcShareCard.ts b/src/lib/usdcShareCard.ts new file mode 100644 index 0000000..32f06b5 --- /dev/null +++ b/src/lib/usdcShareCard.ts @@ -0,0 +1,113 @@ +/** + * The USDC share-of-volume card: one 1024px SVG for posting. Every number comes from src/lib/issuance.ts + * (the same snapshot as /issuance and /summary), so regenerating the card after a backfill refreshes it. + * Dollars are always $B with one decimal, shares always % with one decimal, dates "Mon D, YYYY". + * Colors are navy and red on white; the smallest text is MIN_FONT_PX at the 1024px canvas width. + * Built by scripts/usdc-share-card.ts (npm run card:usdc-share). + */ + +import { LAST_WEEK, SNAPSHOT, totals } from './issuance'; + +export const CARD_WIDTH = 1024; +export const MIN_FONT_PX = 26; +export const NAVY = '#0b2545'; +export const RED = '#c8102e'; +const BG = '#ffffff'; +const FONT = "'Liberation Sans', Helvetica, Arial, sans-serif"; + +export interface CardRow { + asset: string; + gross: number; + share: number; +} + +export interface CardData { + rows: CardRow[]; + total: number; + usdcMint: number; + usdcRedeem: number; + /** Snapshot read time, ISO */ + asOf: string; + lastWeek: string; +} + +export function cardData(): CardData { + const t = totals(); + const total = t.reduce((a, x) => a + x.gross, 0); + const rows = t.map((x) => ({ asset: x.asset.id, gross: x.gross, share: x.gross / total })).sort((a, b) => b.gross - a.gross); + const usdc = t.find((x) => x.asset.id === 'USDC')!; + return { rows, total, usdcMint: usdc.mint, usdcRedeem: usdc.redeem, asOf: SNAPSHOT.flowsAsOf, lastWeek: LAST_WEEK }; +} + +export const fmtUsdB = (usd: number): string => `$${(usd / 1e9).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}B`; +export const fmtPct = (share: number): string => `${(share * 100).toFixed(1)}%`; +export const fmtDate = (iso: string): string => + new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); + +const esc = (s: string) => s.replace(/&/g, '&').replace(/ r.asset === 'USDC')!; + const text = (x: number, y: number, size: number, s: string, o: { weight?: number; fill?: string; anchor?: 'start' | 'end' | 'middle' } = {}) => + `${esc(s)}`; + + const out: string[] = []; + out.push(text(X, 104, 48, `USDC is ${fmtPct(usdc.share)} of the ${fmtUsdB(d.total)}`, { weight: 700 })); + out.push(text(X, 160, 48, 'minted and redeemed on Ethereum', { weight: 700 })); + out.push(text(X, 214, 28, `Six assets, Jan 2023 to the week of ${fmtDate(d.lastWeek)}`)); + + // Share bar: USDC navy, the other five red. + const barY = 258; + const usdcW = Math.round(W * usdc.share); + out.push(``); + out.push(``); + out.push(text(X + 20, barY + 49, 36, `USDC ${fmtPct(usdc.share)}`, { weight: 700, fill: BG })); + out.push(text(X + W, barY + 118, 28, `Other five assets ${fmtPct(1 - usdc.share)}`, { anchor: 'end', weight: 700, fill: RED })); + + // Table: one row per asset, mini bar scaled to share. + const colAsset = X; + const colBar = X + 150; + const barMax = 300; + const colUsd = X + W - 150; + const colPct = X + W; + let y = 440; + out.push(text(colAsset, y, 26, 'Asset', { weight: 700 })); + out.push(text(colUsd, y, 26, 'Mints + redemptions', { anchor: 'end', weight: 700 })); + out.push(text(colPct, y, 26, 'Share', { anchor: 'end', weight: 700 })); + out.push(``); + y += 62; + for (const r of d.rows) { + const color = r.asset === 'USDC' ? NAVY : RED; + out.push(text(colAsset, y, 30, r.asset, { weight: 700, fill: color })); + out.push(``); + out.push(text(colUsd, y, 30, fmtUsdB(r.gross), { anchor: 'end', fill: color })); + out.push(text(colPct, y, 30, fmtPct(r.share), { anchor: 'end', weight: 700, fill: color })); + y += 56; + } + out.push(``); + y += 18; + out.push(text(colAsset, y, 30, 'Total', { weight: 700 })); + out.push(text(colUsd, y, 30, fmtUsdB(d.total), { anchor: 'end', weight: 700 })); + out.push(text(colPct, y, 30, fmtPct(1), { anchor: 'end', weight: 700 })); + + y += 64; + out.push(text(X, y, 28, `USDC: ${fmtUsdB(d.usdcMint)} minted, ${fmtUsdB(d.usdcRedeem)} redeemed.`)); + out.push(text(X, y + 40, 28, 'Mostly market-maker churn. Ethereum logs only.')); + + y += 130; + out.push(``); + out.push(text(X, y + 12, 28, `Data as of ${fmtDate(d.asOf)}`, { weight: 700, fill: RED })); + out.push(text(X + W, y + 12, 28, 'github.com/RahilBhavan/Ondo', { anchor: 'end' })); + + const height = y + 56; + return [ + ``, + `USDC share of mint and redemption volume on Ethereum, data as of ${fmtDate(d.asOf)}`, + ``, + ...out, + '', + '', + ].join('\n'); +}