Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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

```
Expand Down
Binary file added docs/img/usdc-share-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions docs/img/usdc-share-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions scripts/usdc-share-card.ts
Original file line number Diff line number Diff line change
@@ -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}`);
52 changes: 26 additions & 26 deletions src/data/issuance.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down Expand Up @@ -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
}
]
}
38 changes: 38 additions & 0 deletions src/lib/usdcShareCard.check.ts
Original file line number Diff line number Diff line change
@@ -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 [^>]*>([^<]*)<\/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(' ')}`);
113 changes: 113 additions & 0 deletions src/lib/usdcShareCard.ts
Original file line number Diff line number Diff line change
@@ -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, '&amp;').replace(/</g, '&lt;');

export function cardSvg(d: CardData): string {
const X = 56;
const W = CARD_WIDTH - 2 * X;
const usdc = d.rows.find((r) => r.asset === 'USDC')!;
const text = (x: number, y: number, size: number, s: string, o: { weight?: number; fill?: string; anchor?: 'start' | 'end' | 'middle' } = {}) =>
`<text x="${x}" y="${y}" font-size="${size}" font-weight="${o.weight ?? 400}" fill="${o.fill ?? NAVY}" text-anchor="${o.anchor ?? 'start'}">${esc(s)}</text>`;

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(`<rect x="${X}" y="${barY}" width="${usdcW}" height="72" fill="${NAVY}"/>`);
out.push(`<rect x="${X + usdcW}" y="${barY}" width="${W - usdcW}" height="72" fill="${RED}"/>`);
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(`<rect x="${X}" y="${y + 14}" width="${W}" height="2" fill="${NAVY}"/>`);
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(`<rect x="${colBar}" y="${y - 24}" width="${Math.max(2, Math.round(barMax * r.share))}" height="28" fill="${color}"/>`);
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(`<rect x="${X}" y="${y - 30}" width="${W}" height="2" fill="${NAVY}"/>`);
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(`<rect x="${X}" y="${y - 38}" width="${W}" height="2" fill="${RED}"/>`);
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 [
`<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_WIDTH}" height="${height}" viewBox="0 0 ${CARD_WIDTH} ${height}" font-family="${FONT}">`,
`<title>USDC share of mint and redemption volume on Ethereum, data as of ${fmtDate(d.asOf)}</title>`,
`<rect width="${CARD_WIDTH}" height="${height}" fill="${BG}"/>`,
...out,
'</svg>',
'',
].join('\n');
}
Loading