diff --git a/.github/workflows/refresh-snapshots.yml b/.github/workflows/refresh-snapshots.yml new file mode 100644 index 0000000..a5c3fea --- /dev/null +++ b/.github/workflows/refresh-snapshots.yml @@ -0,0 +1,140 @@ +name: refresh-snapshots + +# Reruns the backfill scripts, rewrites src/data/*.json and the README figures, runs the same checks as CI +# (type check and every *.check.ts) and opens a pull request with the result. It never pushes to main. +# No secrets: every source is public and keyless (Tenderly's public gateway, DefiLlama, FRED, Blockscout, public RPCs). +# Needs the repository setting Settings > Actions > General > "Allow GitHub Actions to create and approve pull requests". + +on: + schedule: + - cron: '0 6 * * 1' # Mondays 06:00 UTC, after the previous week has closed + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: refresh-snapshots + +env: + BRANCH: data/refresh-snapshots + +jobs: + refresh: + if: github.repository == 'RahilBhavan/Ondo' + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 20 + cache: npm + - run: npm ci + + # About 25 minutes; the dense USDC, PYUSD and BUIDL series can take 40 and the public gateway may answer 429. + # The script writes its file only when every asset finished, so a failed attempt leaves the data as it was. + - name: backfill stablecoin event logs (src/data/issuance.json) + timeout-minutes: 100 + run: node scripts/backfill-issuance.mjs || { echo "first attempt failed, retrying once"; node scripts/backfill-issuance.mjs; } + + - name: backfill DefiLlama supply and FRED rates (src/data/supply-by-chain.json, rates.json) + timeout-minutes: 10 + run: node scripts/backfill-context.mjs + + # Keeps only parts that came back fully live. If a public source is down it exits 1 and leaves that part as it was, + # so this step may fail without blocking the rest; the pull request says which. + - name: refresh last good values (src/data/last-good.json) + id: lastgood + continue-on-error: true + timeout-minutes: 10 + run: npx tsx scripts/snapshot-last-good.ts + + - name: rewrite README figures and the study guide from the new snapshots + run: | + npm run sync:readme + npx tsx docs/textbook/build.ts + + # A failing check must not hide the new data: the pull request still opens, as a draft, and the job then fails. + - name: type check + id: tsc + continue-on-error: true + run: npx tsc --noEmit + - name: self-checks (offline) + id: tests + continue-on-error: true + run: npm test + + - name: open or update the pull request + env: + GH_TOKEN: ${{ github.token }} + LASTGOOD_OUTCOME: ${{ steps.lastgood.outcome }} + CHECKS_OUTCOME: ${{ steps.tsc.outcome == 'success' && steps.tests.outcome == 'success' && 'success' || 'failure' }} + run: | + if [ -z "$(git status --porcelain -- src/data README.md docs/textbook)" ]; then + echo "no change to the snapshots" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add src/data README.md docs/textbook/index.html + git commit -m "data: refresh snapshots $(date -u +%F)" + git push --force origin "$BRANCH" + + run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + { + echo "## Summary" + echo + echo "Scheduled refresh of the stablecoin flows snapshots ($run_url)." + echo + echo '```' + git diff --stat HEAD~1 HEAD + echo '```' + echo + if [ "$CHECKS_OUTCOME" = "success" ]; then + echo "- Type check and every \`*.check.ts\` passed in the run, offline." + else + echo "- **A check failed on the new data. Do not merge.** This pull request is a draft; the run log has the failing output." + fi + if [ "$LASTGOOD_OUTCOME" = "success" ]; then + echo "- \`last-good.json\`: every part came back live and was refreshed." + else + echo "- \`last-good.json\`: **not fully refreshed**, a source was not fully live. Parts that failed keep their earlier dated rows. See the run log." + fi + echo + echo "## Before merging" + echo + echo "- [ ] Reread the hand-written README bullet on the rate-cut episodes (the sigma values); the workflow does not rewrite it." + echo "- [ ] \`ci.yml\` does not start for pull requests opened with the workflow token. Close and reopen this pull request to run lint and build." + echo "- [ ] \`docs/img/usdc-share-card.*\` and \`docs/img/summary.png\` are not regenerated here." + echo + echo "๐Ÿค– Opened by .github/workflows/refresh-snapshots.yml" + } > "$RUNNER_TEMP/pr-body.md" + + title="data: refresh snapshots $(date -u +%F)" + n=$(gh pr list --head "$BRANCH" --state open --json number -q '.[0].number') + draft="" + [ "$CHECKS_OUTCOME" = "success" ] || draft="--draft" + if [ -n "$n" ]; then + gh pr edit "$n" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md" + [ -z "$draft" ] || gh pr ready "$n" --undo + else + gh pr create --base main --head "$BRANCH" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md" $draft + fi + + - name: fail the job if a check failed (the alert below then fires) + if: always() && (steps.tsc.outcome == 'failure' || steps.tests.outcome == 'failure') + run: exit 1 + + - name: alert (one open issue per failure streak; comment on it if already open) + if: failure() + env: + GH_TOKEN: ${{ github.token }} + run: | + title="Snapshot refresh failed" + url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + n=$(gh issue list --state open --search "\"$title\" in:title" --json number -q '.[0].number') + if [ -n "$n" ]; then gh issue comment "$n" --body "Failed again: $url" + else gh issue create --title "$title" --body "Run: $url"; fi diff --git a/README.md b/README.md index 1988ec7..737e536 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,11 @@ A Next.js dashboard that decodes Ondo's OUSG and USDY InstantManager events on E ## Results -From the dated snapshot in `src/data/issuance.json` (event logs read 2026-10-24, Ethereum only, weeks of 26 Dec 2022 through 12 Oct 2026): +The figures in this section and the dates below come from the committed snapshots. `npm run sync:readme` rewrites them, and `npm test` fails if they drift. The bullet on the 2024 and 2025 rate-cut episodes is hand-written: reread it after every refresh. + + + +From the dated snapshot in `src/data/issuance.json` (event logs read 2026-10-25 UTC, Ethereum only, weeks of 26 Dec 2022 through 12 Oct 2026): | Asset | Mints | Redemptions | Net | |-------|------:|------------:|----:| @@ -23,10 +27,22 @@ From the dated snapshot in `src/data/issuance.json` (event logs read 2026-10-24, | USDY | $250.1M | $68.8M | $181.3M | | **Five issuers** | **$526.3B** | **$490.5B** | **$35.8B** | -- The snapshot holds 3,212,656 decoded event logs (the `audit` counts in `src/data/issuance.json`). USDC is 3.17M of them. + + + + +- The snapshot holds 3,217,426 decoded event logs (the `audit` counts in `src/data/issuance.json`). USDC is 3.18M of them. - The five issuers total about $1T of mints and redemptions: $1.02T after removing $192.1B of Circle CCTP transfers, $1.21T if they were counted (`grossUsd` and `grossWithCctpUsd` in `src/lib/issuance.ts`). The tracker excludes CCTP because moving USDC between chains is not new money. It also excludes Paxos's mistaken 300 trillion PYUSD mint and its burn on 2025-10-15. + + + - In the 2024 easing episode (Sep to Dec 2024, 100 bp), USDC and USDT net issuance ran above their own norm (+0.5ฯƒ each), and BUIDL and OUSG ran below theirs (โˆ’0.2ฯƒ and โˆ’0.3ฯƒ). The 2025 episode (Sep to Dec 2025, 75 bp) only partly repeated it. With two episodes this is descriptive, not evidence of cause. `/summary` renders these figures from `src/lib/issuance.ts`. -- Ethereum log-derived net issuance matched DefiLlama's Ethereum supply change within 17% for USDC and 7% for PYUSD (`src/lib/issuance.check.ts`, run 2026-10-24). + + + +- Ethereum log-derived net issuance matched DefiLlama's Ethereum supply change within 18% for USDC and 8% for PYUSD (`src/lib/issuance.check.ts`, checked against the snapshot read 2026-10-25). + + ## How it works @@ -47,15 +63,19 @@ npm test npm run dev # serves on port 3000; the deployed copy is ondo-nexus.vercel.app ``` -`npm test` runs the seven self-check scripts in `src/lib/*.check.ts` with `tsx` (pinned in `package.json`). They need no network. All seven passed on 2026-10-26. `issuance.check.ts` reconciles the committed snapshots. CI runs lint, the type check, `npm test` and `npm run build`. +`npm test` runs the self-check scripts in `src/lib/*.check.ts` with `tsx` (pinned in `package.json`). They work from the committed snapshots and need no network: each one is preloaded with `scripts/no-network.mjs`, which makes any `fetch`, DNS lookup or socket connection throw, so a check that reaches for the network fails. `issuance.check.ts` reconciles the committed snapshots and `readmeSync.check.ts` checks the README against them. CI runs lint, the type check, `npm test` and `npm run build`. + +The snapshots refresh themselves. `.github/workflows/refresh-snapshots.yml` runs every Monday at 06:00 UTC (and on demand from the Actions tab). It reruns the backfill scripts and `scripts/snapshot-last-good.ts`, rewrites the README figures, runs the type check and `npm test`, and opens a pull request with the changed `src/data/*.json`. It needs no secrets: every source is public and keyless. Nothing merges by itself. -To refresh the stablecoin snapshots (about 25 minutes on the free public RPC), then rebuild the study guide: +To refresh by hand (about 25 minutes on the free public RPC): ```bash node scripts/backfill-issuance.mjs node scripts/backfill-context.mjs -npx tsx src/lib/issuance.check.ts -npx tsx docs/textbook/build.ts +npx tsx scripts/snapshot-last-good.ts +npm run sync:readme +npm test +npx tsx docs/textbook/build.ts # rebuild the study guide ``` 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). @@ -89,8 +109,28 @@ docs/ Methodology, contract research, decisions and the study guide - **Chain supply and price.** public RPCs and chain APIs on 13 chains, priced with `OndoOracle` on Ethereum. The per-chain table is in [docs/METHODOLOGY.md](docs/METHODOLOGY.md). - **Holders.** Blockscout token holders API for Ethereum (OUSG, USDY) and the stellar.expert asset holders API for Stellar USDY. - **Benchmark.** [DefiLlama](https://defillama.com) TVL per product, plus redemption terms quoted from each issuer's docs. -- **Stablecoin flows.** Ethereum event logs through Tenderly's public gateway, DefiLlama stablecoin supply and FRED `DFEDTARU`. The backfill scripts freeze these as dated JSON in `src/data/`, with `asOf` 2026-10-24. -- **Last good values.** `src/data/last-good.json` holds real reads from the sources above, written on 2026-10-26 (Superstate USTB and OpenEden TBILL rows date from 2026-09-24). +- **Stablecoin flows.** Ethereum event logs through Tenderly's public gateway, DefiLlama stablecoin supply and FRED `DFEDTARU`. The backfill scripts freeze these as dated JSON in `src/data/`: event logs with `asOf` 2026-10-25, supply by chain 2026-10-24, rates 2026-10-24 (UTC dates). +- **Last good values.** `src/data/last-good.json` holds real reads from the sources above, written on 2026-10-26 (Superstate USTB and OpenEden TBILL rows date from 2026-09-24). + +## How this was built + + + +AI coding agents wrote much of the code and documentation. Claude Code is credited as co-author on most commits (20 of 30 when this section was written; `git log --grep "Co-authored-by: Claude" -i`), and `.claude/` holds the agents, commands and skill it ran with. Rahil Bhavan is the author of the project and its PRD, and owns what it claims. + +What Rahil designed, as the repo records it: + +- **The question and the scope.** The PRD sets the goal: an independent, on-chain view of Ondo Nexus adoption. The decision records in [docs/DECISIONS.md](docs/DECISIONS.md) fix the scope: OUSG and USDY InstantManager activity stands in for Nexus, which has no contracts of its own (ADR-003); volume and frequency replace a settlement delta that atomic redemptions make unmeasurable (ADR-004); Circle CCTP transfers and Paxos's mistaken 300 trillion PYUSD mint are not issuance (ADR-009). +- **The integrity rules.** Every metric carries a live, estimated or last good badge, and no number is invented: a failed read shows an earlier real read with its date, or nothing (ADR-010, `CLAUDE.md`). +- **The metric definitions** in [docs/METHODOLOGY.md](docs/METHODOLOGY.md) and the Dune SQL in `queries/`. + +What was checked against an independent source, as the docs record it: + +- The decoded weekly Ondo flows matched Dune query 8822192 on all 157 (week, token) rows through the week of 2026-09-14, to the cent on volume (ADR-008). +- Chain supply matched an independent explorer to the token on Ethereum USDY and OUSG, Mantle USDY and Plume USDY, and Stellar USDY matched stellar.expert within 0.000005% (`scripts/verify-supply.ts`, METHODOLOGY Pillar 3). +- Log-derived net issuance for USDC and PYUSD is reconciled against DefiLlama's Ethereum supply change in `src/lib/issuance.check.ts`. + +What was not independently checked: supply on the other chains rests on each chain's own RPC or API (METHODOLOGY Pillar 3); the Superstate USTB and OpenEden TBILL rows are an old snapshot; and the textbook's reading list was written from memory and its citations still need verifying (DEVLOG, 2026-10-17). ## Scope and limits @@ -98,7 +138,7 @@ docs/ Methodology, contract research, decisions and the study guide - Ondo Nexus has no contracts of its own. The dashboard uses OUSG and USDY InstantManager activity as its on-chain proxy ([ADR-003](docs/DECISIONS.md)). - It counts instant mints and redemptions only. Secondary transfers, DEX trades and bridged balances are out of scope. - Gross flows are Ethereum only. Tron holds about half of USDT supply, so Ethereum logs understate USDT and USDC issuance. Net issuance for the three stablecoins uses all-chain supply instead. -- The stablecoin data are a snapshot from 2026-10-24. It is stale until someone reruns the backfill scripts. +- The stablecoin data are a dated snapshot (event logs read 2026-10-25), not a live read. A weekly workflow proposes a refresh as a pull request, so the snapshot is as fresh as the last merged refresh. - The rate-cut comparison covers two easing episodes, so it is descriptive only. - On 2026-10-21, DefiLlama's `/tvl/` endpoint returned `0` for all four benchmark products. The USTB and TBILL rows therefore show their 2026-09-24 last good value, labeled **last good**. diff --git a/STATUS.md b/STATUS.md index bb8ea74..b088edb 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,5 @@ # Nexus Dashboard โ€” Current Status -Last updated: 2026-10-24 +Last updated: 2026-10-29 ## Phase [x] Phase 0 โ€” Contract Research @@ -10,7 +10,8 @@ Last updated: 2026-10-24 [x] Study textbook for the outreach (`docs/textbook/index.html`, rebuilt from the data snapshots) [x] Deployed to Vercel (ondo-nexus.vercel.app, personal scope) [x] Benchmark: BUIDL live from DefiLlama's stablecoin series (#54); USTB and TBILL stay labeled snapshots (no free source) -[x] Stablecoin snapshots rerun 2026-10-24 +[x] Stablecoin snapshots rerun 2026-10-25 (event logs), 2026-10-24 (supply, rates) +[x] Weekly `refresh-snapshots` workflow proposes snapshot refreshes as a pull request; `npm test` runs offline and checks the README against the snapshots ## Live Pages - `/` โ€” home: KPIs, top holders, chain breakdown, heatmap, 12-week flows, benchmark @@ -28,7 +29,8 @@ Last updated: 2026-10-24 ## Open Items - After refreshing the snapshots, rebuild the textbook: `npx tsx docs/textbook/build.ts`. -- Flows tracker data are dated snapshots (last run 2026-10-24): rerun `node scripts/backfill-issuance.mjs` (about 25 min for the first pass; the dense USDC, PYUSD and BUIDL series take about 40 min at the 10k-block window and Tenderly's public gateway may 429, so rerun those with `node scripts/backfill-issuance.mjs USDC PYUSD BUIDL`) and `node scripts/backfill-context.mjs`, then `npx tsx src/lib/issuance.check.ts`. +- Flows tracker data are dated snapshots (event logs read 2026-10-25). `.github/workflows/refresh-snapshots.yml` reruns the backfill scripts every Monday and opens a pull request; merge it after reading the hand-written README bullet on the rate cuts. By hand: `node scripts/backfill-issuance.mjs` (about 25 min; the dense USDC, PYUSD and BUIDL series can take 40 min and Tenderly's public gateway may 429), `node scripts/backfill-context.mjs`, `npm run sync:readme`, `npm test`, then `npx tsx docs/textbook/build.ts`. +- Rahil action: confirm or edit the README section "How this was built" (drafted by an agent from the history), and check Settings > Actions > General allows Actions to create pull requests. - Flows tracker is Ethereum logs only; Tron USDT and non-Ethereum USDC issuance are covered through supply, not logs. - BUIDL chain count and redemption terms: no issuer page to cite, shown as "Not disclosed". - Rahil action: rotate the Dune API key (the app no longer uses Dune; only the MCP server does). diff --git a/docs/textbook/index.html b/docs/textbook/index.html index f69f7b4..a2f6fb0 100644 --- a/docs/textbook/index.html +++ b/docs/textbook/index.html @@ -180,7 +180,7 @@

A student trynna learn ยท Ondo Nexus flows tracker

Stablecoin flows, explained

What you need to know to defend the tracker: how stablecoins are made and destroyed, who issues them, how to read the chain, how to measure flows without fooling yourself, and what a finance professor will ask.

-

Written for Rahil before emailing professors Rajan, Adriaens and Wu. Numbers come from the dashboard's own snapshots (data read Oct 24, 2026); they are rebuilt from the repository by docs/textbook/build.ts. Study notes, not investment advice.

+

Written for Rahil before emailing professors Rajan, Adriaens and Wu. Numbers come from the dashboard's own snapshots (data read Oct 25, 2026); they are rebuilt from the repository by docs/textbook/build.ts. Study notes, not investment advice.

Read in order, once

Chapters 1 to 3 are the money. 4 and 5 are the measurement. 6 and 7 are the economics. 8 is your result. 9 is the conversation.

@@ -713,7 +713,7 @@

Result 1: size

0.21%USDYOndo $250M$68.8M$181M 0.03% -
Compare the bars: gross volume (mints plus redemptions) as a share of the total. Ethereum event logs only, from 2022-12-26 to the week of 2026-10-12. USDC net is negative here because USDC also lives on other chains (chapter 5). Source: src/data/issuance.json, snapshot 2026-10-24.
+
Compare the bars: gross volume (mints plus redemptions) as a share of the total. Ethereum event logs only, from 2022-12-26 to the week of 2026-10-12. USDC net is negative here because USDC also lives on other chains (chapter 5). Source: src/data/issuance.json, snapshot 2026-10-25.

Result 2: the rate episodes

@@ -910,7 +910,7 @@

Method

7Chapter 9Read each professor's recent work. Rewrite the draft email. Rehearse the nine answers aloud.Send nothing until you have checked every number against the page.
- + diff --git a/package.json b/package.json index 8df5234..ede9492 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "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 --import ./scripts/no-network.mjs \"$f\" || exit 1; done", + "sync:readme": "tsx scripts/sync-readme.ts", "card:usdc-share": "tsx scripts/usdc-share-card.ts", "card:usdc-share:fresh": "tsx scripts/usdc-share-card.ts --refresh" }, diff --git a/scripts/no-network.mjs b/scripts/no-network.mjs new file mode 100644 index 0000000..de88e9c --- /dev/null +++ b/scripts/no-network.mjs @@ -0,0 +1,28 @@ +/** + * Preload for `npm test`: any outbound network call from a self-check fails the check. + * Run a check by hand with: npx tsx --import ./scripts/no-network.mjs src/lib/.check.ts + * + * Blocks fetch, DNS lookups and TCP/TLS connects (http, https, viem and undici all end up in one of these). + * Unix-socket connects are left alone, because tsx uses one to talk to its own loader process. + */ +import dns from 'node:dns'; +import net from 'node:net'; +import tls from 'node:tls'; + +const refuse = (what) => { + throw new Error(`network access attempted in an offline check: ${what}`); +}; + +globalThis.fetch = (input) => refuse(`fetch ${typeof input === 'string' ? input : (input?.url ?? String(input))}`); + +for (const fn of ['lookup', 'resolve', 'resolve4', 'resolve6']) dns[fn] = (host) => refuse(`dns.${fn} ${host}`); + +const connect = net.Socket.prototype.connect; +net.Socket.prototype.connect = function (...args) { + const first = args[0]; + const unix = typeof first === 'string' || (first && typeof first === 'object' && 'path' in first); + if (unix) return connect.apply(this, args); + const host = first && typeof first === 'object' ? `${first.host ?? 'localhost'}:${first.port}` : `port ${first}`; + return refuse(`tcp connect ${host}`); +}; +tls.connect = (...args) => refuse(`tls connect ${JSON.stringify(args[0]?.host ?? args[0])}`); diff --git a/scripts/sync-readme.ts b/scripts/sync-readme.ts new file mode 100644 index 0000000..61fad8e --- /dev/null +++ b/scripts/sync-readme.ts @@ -0,0 +1,16 @@ +/** + * Rewrite the README's snapshot dates and results from src/data/*.json (see src/lib/readmeSync.ts). + * Run after a data refresh: npm run sync:readme + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { renderReadme } from '../src/lib/readmeSync'; + +const FILE = join(__dirname, '..', 'README.md'); +const before = readFileSync(FILE, 'utf8'); +const after = renderReadme(before); +if (after === before) console.log('README.md already matches the snapshots'); +else { + writeFileSync(FILE, after); + console.log('README.md updated from the snapshots'); +} diff --git a/src/lib/chainSupply.check.ts b/src/lib/chainSupply.check.ts index b344f62..0df562b 100644 --- a/src/lib/chainSupply.check.ts +++ b/src/lib/chainSupply.check.ts @@ -1,4 +1,4 @@ -/** Self-check for chain supply parsing and fallback. No network. Run: npx tsx src/lib/chainSupply.check.ts */ +/** Self-check for chain supply parsing and fallback. No network (the failed reads below are simulated). Run: npx tsx src/lib/chainSupply.check.ts */ import assert from 'node:assert/strict'; import { buildRows, fromBaseUnits, stellarSupply, SUPPLY_SOURCES, toLiquidityCells, xrplSupply } from './chainSupply'; import { LAST_GOOD } from './lastGood'; @@ -75,7 +75,9 @@ assert.equal(cells[1].issuer, mixed[1].token); assert.equal(cells[5].dataSource, 'cached'); // No last good row and a failed read: the row is left out, never invented. +console.warn = () => {}; const gap = buildRows(SUPPLY_SOURCES, oneFail, prices, mock.filter((m) => !(m.chain === SUPPLY_SOURCES[5].chain && m.token === SUPPLY_SOURCES[5].token)), asOf); +console.warn = warn; assert.equal(gap.length, SUPPLY_SOURCES.length - 1); console.log(`chainSupply check ok (last good total $${(mockTotal / 1e9).toFixed(3)}B, ${SUPPLY_SOURCES.length} rows)`); diff --git a/src/lib/issuance.check.ts b/src/lib/issuance.check.ts index b283fd5..b46c553 100644 --- a/src/lib/issuance.check.ts +++ b/src/lib/issuance.check.ts @@ -3,36 +3,28 @@ * Reconciles the log-derived flows with independent sources and pins the claims the summary makes. */ import assert from 'node:assert/strict'; -import issuance from '../data/issuance.json'; -import supply from '../data/supply-by-chain.json'; -import { episodeResults, episodes, grossIssuerVolume, issuanceSummary, supplyTotalAt, totals, zDuring } from './issuance'; +import { episodeResults, episodes, grossIssuerVolume, issuanceSummary, logVsSupply, supplyTotalAt, totals, zDuring } from './issuance'; const B = 1e9; const t = new Map(totals().map((x) => [x.asset.id, x])); -// 1. Ondo agrees with the existing pipeline's documented totals (CONTRACT_RESEARCH, 2026-09-23); later weeks only add. -assert.ok(t.get('OUSG')!.mint >= 1334e6 && t.get('OUSG')!.mint < 1450e6, `OUSG mints ${t.get('OUSG')!.mint}`); -assert.ok(t.get('USDY')!.mint >= 174e6 && t.get('USDY')!.mint < 260e6, `USDY mints ${t.get('USDY')!.mint}`); +// 1. Ondo agrees with the existing pipeline's documented totals (CONTRACT_RESEARCH, 2026-09-23); later weeks only add, +// so the floor is the documented total and the cap only catches a decode blow-up (the scheduled refresh grows these). +assert.ok(t.get('OUSG')!.mint >= 1334e6 && t.get('OUSG')!.mint < 10 * B, `OUSG mints ${t.get('OUSG')!.mint}`); +assert.ok(t.get('USDY')!.mint >= 174e6 && t.get('USDY')!.mint < 5 * B, `USDY mints ${t.get('USDY')!.mint}`); // 2. The 300 trillion PYUSD mistake is excluded, PYUSD volume is the size of its supply history. assert.ok(t.get('PYUSD')!.mint < 100 * B, 'PYUSD outlier leaked in'); // 3. Ethereum mints less burns (CCTP included) tracks DefiLlama's Ethereum supply change, within 25% for USDC and PYUSD. -const rows = issuance.rows as Array<{ asset: string; mintUsd: number; redeemUsd: number }>; -const eth = (asset: 'USDC' | 'USDT' | 'PYUSD') => { - const w = supply.rows.filter((r) => r.asset === asset && r.chain === 'Ethereum'); - const first = w.find((r) => r.supplyUsd > 0)!; - return w[w.length - 1].supplyUsd - first.supplyUsd; -}; for (const a of ['USDC', 'PYUSD'] as const) { - const logNet = rows.filter((r) => r.asset === a).reduce((s, r) => s + r.mintUsd - r.redeemUsd, 0); - const diff = Math.abs(logNet - eth(a)) / Math.abs(eth(a)); - console.log(a, 'log net', (logNet / B).toFixed(2), 'DefiLlama Ethereum change', (eth(a) / B).toFixed(2), `diff ${(diff * 100).toFixed(0)}%`); + const { logNet, supplyChange, diff } = logVsSupply(a); + console.log(a, 'log net', (logNet / B).toFixed(2), 'DefiLlama Ethereum change', (supplyChange / B).toFixed(2), `diff ${(diff * 100).toFixed(0)}%`); assert.ok(diff < 0.25, `${a} logs and supply disagree by ${diff}`); } // USDT: Tether destroys and re-issues outside the Issue/Redeem path (e.g. blacklisted funds), so only the sign and order of magnitude. -const usdtLog = rows.filter((r) => r.asset === 'USDT').reduce((s, r) => s + r.mintUsd - r.redeemUsd, 0); -assert.ok(usdtLog > 0.5 * eth('USDT') && usdtLog < 1.5 * eth('USDT'), 'USDT logs and supply disagree'); +const usdt = logVsSupply('USDT'); +assert.ok(usdt.logNet > 0.5 * usdt.supplyChange && usdt.logNet < 1.5 * usdt.supplyChange, 'USDT logs and supply disagree'); // 4. Global supply total matches DefiLlama's headline (USDC about $72.6B on 2026-10-17). assert.ok(supplyTotalAt('USDC', '2026-10-12') > 70 * B && supplyTotalAt('USDC', '2026-10-12') < 76 * B); diff --git a/src/lib/issuance.ts b/src/lib/issuance.ts index 4ac029a..3dfde7d 100644 --- a/src/lib/issuance.ts +++ b/src/lib/issuance.ts @@ -292,6 +292,18 @@ export function supplyTotalAt(asset: 'USDC' | 'USDT' | 'PYUSD', week: string): n return SUPPLY.filter((r) => r.asset === asset && r.week === week).reduce((a, r) => a + r.supplyUsd, 0); } +/** + * Ethereum mints less burns from the event logs, against DefiLlama's Ethereum circulating-supply change from the + * first week with supply to the last. The log net includes CCTP; diff is |log - supply| / |supply|. + */ +export function logVsSupply(asset: 'USDC' | 'USDT' | 'PYUSD'): { logNet: number; supplyChange: number; diff: number } { + const w = SUPPLY.filter((r) => r.asset === asset && r.chain === 'Ethereum'); + const first = w.find((r) => r.supplyUsd > 0)!; + const change = w[w.length - 1].supplyUsd - first.supplyUsd; + const logNet = ROWS.filter((r) => r.asset === asset).reduce((a, r) => a + r.mintUsd - r.redeemUsd, 0); + return { logNet, supplyChange: change, diff: Math.abs(logNet - change) / Math.abs(change) }; +} + export const fedFundsSeries = (): Array<{ date: string; value: number }> => rates.upper as Array<{ date: string; value: number }>; // --- News events --- diff --git a/src/lib/readmeSync.check.ts b/src/lib/readmeSync.check.ts new file mode 100644 index 0000000..785ec7b --- /dev/null +++ b/src/lib/readmeSync.check.ts @@ -0,0 +1,23 @@ +/** Self-check that README.md matches the committed snapshots. No network. Run: npx tsx src/lib/readmeSync.check.ts */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { renderReadme } from './readmeSync'; + +const readme = readFileSync(join(__dirname, '..', '..', 'README.md'), 'utf8'); + +// The README is what the snapshots render. On failure run `npm run sync:readme` and review the diff. +assert.equal(renderReadme(readme), readme, 'README.md is out of step with src/data/*.json: run `npm run sync:readme`'); + +// Rendering is idempotent, and a date marker takes its date from the snapshot, not from the text. +assert.equal(renderReadme(renderReadme(readme)), readme); +const stale = readme.replace(/()[^<]*/, '$12000-01-01'); +assert.notEqual(stale, readme); +assert.equal(renderReadme(stale), readme); + +// Unknown markers and a README with none are errors, not silent no-ops. +assert.throws(() => renderReadme('x'), /unknown asof key/); +assert.throws(() => renderReadme('x'), /unknown snapshot block/); +assert.throws(() => renderReadme('no markers'), /no asof or snapshot markers/); + +console.log('readmeSync check ok'); diff --git a/src/lib/readmeSync.ts b/src/lib/readmeSync.ts new file mode 100644 index 0000000..a94acf0 --- /dev/null +++ b/src/lib/readmeSync.ts @@ -0,0 +1,90 @@ +/** + * Keeps the README's snapshot figures in step with src/data/*.json. + * + * Two kinds of marker in README.md are rewritten from the committed snapshots: + * date a UTC date; KEY is flows, supply, rates or lastGood + * ... a generated block; NAME is results or counts + * Everything else in the README is hand-written and left alone. Run `npm run sync:readme` after a data + * refresh; readmeSync.check.ts fails when the README has drifted from the snapshots. + */ + +import issuance from '../data/issuance.json'; +import { LAST_GOOD } from './lastGood'; +import { formatTrillions } from './format'; +import { issuanceSummary, logVsSupply, SNAPSHOT } from './issuance'; + +const utcDate = (iso: string | null): string => { + if (!iso) throw new Error('snapshot has no timestamp'); + return new Date(iso).toISOString().slice(0, 10); +}; + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +const dayMonthYear = (iso: string): string => { + const [y, m, d] = iso.split('-').map(Number); + return `${d} ${MONTHS[m - 1]} ${y}`; +}; + +/** $420.5B, $748.5M, โˆ’$23.7B (U+2212 for negatives, as the README has always written them). */ +const usd = (v: number): string => { + const a = Math.abs(v); + const s = a >= 1e9 ? `$${(a / 1e9).toFixed(1)}B` : `$${(a / 1e6).toFixed(1)}M`; + return v < 0 ? `โˆ’${s}` : s; +}; + +const asOfDates = (): Record => ({ + flows: utcDate(SNAPSHOT.flowsAsOf), + supply: utcDate(SNAPSHOT.supplyAsOf), + rates: utcDate(SNAPSHOT.ratesAsOf), + lastGood: utcDate(LAST_GOOD.generatedAt), +}); + +function resultsBlock(): string { + const s = issuanceSummary(); + const sum = (k: 'mintUsd' | 'redeemUsd' | 'netUsd') => s.totals.reduce((a, t) => a + t[k], 0); + const rows = s.totals.map((t) => `| ${t.asset} | ${usd(t.mintUsd)} | ${usd(t.redeemUsd)} | ${usd(t.netUsd)} |`); + return [ + `From the dated snapshot in \`src/data/issuance.json\` (event logs read ${utcDate(s.asOf)} UTC, Ethereum only, weeks of ${dayMonthYear(s.startWeek)} through ${dayMonthYear(s.lastWeek)}):`, + '', + '| Asset | Mints | Redemptions | Net |', + '|-------|------:|------------:|----:|', + ...rows, + `| **Five issuers** | **${usd(sum('mintUsd'))}** | **${usd(sum('redeemUsd'))}** | **${usd(sum('netUsd'))}** |`, + ].join('\n'); +} + +function countsBlock(): string { + const s = issuanceSummary(); + const audit = issuance.audit as Record; + const events = Object.values(audit).reduce((a, n) => a + n, 0); + const usdc = (audit['USDC:mint'] ?? 0) + (audit['USDC:redeem'] ?? 0); + return [ + `- The snapshot holds ${events.toLocaleString('en-US')} decoded event logs (the \`audit\` counts in \`src/data/issuance.json\`). USDC is ${(usdc / 1e6).toFixed(2)}M of them.`, + `- The five issuers total about $1T of mints and redemptions: ${formatTrillions(s.grossUsd)} after removing ${usd(s.cctpBridgedUsd)} of Circle CCTP transfers, ${formatTrillions(s.grossWithCctpUsd)} if they were counted (\`grossUsd\` and \`grossWithCctpUsd\` in \`src/lib/issuance.ts\`). The tracker excludes CCTP because moving USDC between chains is not new money. It also excludes Paxos's mistaken 300 trillion PYUSD mint and its burn on 2025-10-15.`, + ].join('\n'); +} + +function reconcileBlock(): string { + const pct = (a: 'USDC' | 'PYUSD') => Math.ceil(logVsSupply(a).diff * 100); + return `- Ethereum log-derived net issuance matched DefiLlama's Ethereum supply change within ${pct('USDC')}% for USDC and ${pct('PYUSD')}% for PYUSD (\`src/lib/issuance.check.ts\`, checked against the snapshot read ${asOfDates().flows}).`; +} + +const BLOCKS: Record string> = { results: resultsBlock, counts: countsBlock, reconcile: reconcileBlock }; + +/** Returns the README with every marker rewritten; throws if a marker names something unknown or none is found. */ +export function renderReadme(readme: string): string { + const dates = asOfDates(); + let found = 0; + const withDates = readme.replace(/[^<]*/g, (_m, key: string) => { + if (!dates[key]) throw new Error(`README: unknown asof key "${key}"`); + found++; + return `${dates[key]}`; + }); + const out = withDates.replace(/[\s\S]*?/g, (_m, name: string) => { + const block = BLOCKS[name]; + if (!block) throw new Error(`README: unknown snapshot block "${name}"`); + found++; + return `\n\n${block()}\n\n`; + }); + if (!found) throw new Error('README: no asof or snapshot markers found'); + return out; +}