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 @@
+
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 [
+ `',
+ '',
+ ].join('\n');
+}