diff --git a/docs/analysis/income-tiers.md b/docs/analysis/income-tiers.md new file mode 100644 index 000000000..4b43ed0e1 --- /dev/null +++ b/docs/analysis/income-tiers.md @@ -0,0 +1,94 @@ +# Mining income per tier on igneum-testnet-1 + +Generated by `tools/launch/income-tiers.mjs` from `tools/launch/income-tiers.json` (mission item 10, the consequences rule). Do not edit by hand: `node tools/launch/income-tiers.mjs` rewrites it and `--check` fails CI when the two disagree. Every rate and watt figure is a measurement with its source in the first table; nothing is estimated. Testnet coins have no value and the figures below say nothing about any price: the dollar columns are electricity, a cost the reader computes from a measured watt figure and a tariff they choose. + +## The schedule the figures use + +`EmissionSchedule::TESTNET_1` (the testnet genesis, decided 7 October 2026): 100 IGN a block at one block a second, a 90-day ramp from 10 percent, a monthly glide with a two-year half-life (each month pays 2^(-1/24) of the month before), then 1 percent of supply a year from about year 11.4. Of each block, 80 percent goes to the miner who found it and 20 percent to the proving pool. A miner whose key signs its checkpoints keeps the full 80 percent; an unsigned key gives a tenth of it to the proving pool. + +| Moment | IGN a block to the miner | Of the launch figure | +|---|---|---| +| day 1 | 8.00 | 10.0% | +| day 30 | 32.00 | 40.0% | +| day 90 (ramp over) | 75.51 | 94.4% | +| month 12 | 58.23 | 72.8% | +| year 2 | 41.17 | 51.5% | + +A solo card's expected income is its share of network hash times 86,400 blocks a day times the figure above. The tables use day 90, when the ramp is over. Before that, multiply by the ramp row; after that, by the glide row. A pool user gets the same expected amount minus the pool's fee (pool-0: 1 percent) with the variance taken out; the reference pool holds no balance and pays from the coinbase split. + +## The cards, measured + +6 October 2026, rented single-card Linux boxes (docs/analysis/prover-tiers-real-cards.md, bench log 'Prover tiers on real cards: the rented fleet, 6 October 2026'): the 0.3.12 miner on the ten-field override, the miner alone on the card, nvidia-smi power at the same second. The RX 9070 XT row is the telemetry run of 5 October 2026 on a Windows rig (bench log 'Power, heat, fans and clocks, measured'); the Apple row is the Metal bench of 4 October 2026 (site/miner-bench.json, class v4). The class v4 rates on the live devnet run lower than the ten-field rates on the same card (RTX 5090: 124.2 MH/s class v4 against 98.5 on the rented box under a different driver and container, docs/bench-log.md); the table is re-generated at the testnet cut from that class. + +| Tier | Card | MH/s | W | MH/W | Source | +|---|---|---|---|---|---| +| 8 GB | NVIDIA RTX 4060 Ti 8 GB | 19.07 | 72.6 | 0.263 | docs/analysis/prover-tiers-real-cards.md, RTX 4060 Ti 8 GB row | +| 8 GB | NVIDIA RTX 4060 | 17.07 | owed | owed | docs/analysis/prover-tiers-real-cards.md, RTX 4060 row (the sampler read 0.0 W on that host: power OWED) | +| 12 GB | NVIDIA RTX 3060 | 23.78 | 103.7 | 0.229 | docs/analysis/prover-tiers-real-cards.md, RTX 3060 row | +| 12 GB | NVIDIA RTX 4070 | 24.99 | 91.1 | 0.274 | docs/analysis/prover-tiers-real-cards.md, RTX 4070 row | +| 12 GB | NVIDIA RTX 5070 | 41.89 | 137.0 | 0.306 | docs/analysis/prover-tiers-real-cards.md, RTX 5070 row | +| 16 GB | NVIDIA RTX 4060 Ti 16 GB | 17.58 | 72.3 | 0.243 | docs/analysis/prover-tiers-real-cards.md, RTX 4060 Ti 16 GB row | +| 16 GB | AMD RX 9070 XT | 18.60 | 199.0 | 0.093 | docs/bench-log.md, 'Power, heat, fans and clocks, measured' (5 October 2026): 18.6 to 19.2 MH/s, 199 W read by the ADLX helper at 100 percent busy | +| 24 GB | NVIDIA RTX 3090 | 37.79 | 228.8 | 0.165 | docs/analysis/prover-tiers-real-cards.md, RTX 3090 row | +| 24 GB | NVIDIA RTX 4090 | 52.25 | 183.1 | 0.285 | docs/analysis/prover-tiers-real-cards.md, RTX 4090 row | +| 32 GB | NVIDIA RTX 5090 | 98.48 | 258.2 | 0.381 | docs/analysis/prover-tiers-real-cards.md, RTX 5090 row (the rented box; 124.2 MH/s class v4 on a desktop, site/miner-bench.json) | +| Apple | Apple M5 Max (40 GPU cores) | 26.70 | owed | owed | site/miner-bench.json, Apple M5 Max class v4 row, 4 October 2026 (no power reading: OWED) | + +## IGN a day per card, solo, after the ramp (day 90) + +Three network sizes, because income is a share of the network and nobody knows the network before it exists. 1 GH/s, 10 GH/s, 100 GH/s are reference sizes, not forecasts. The live network's estimate is on /live; divide by it. + +| Tier | Card | at 1 GH/s | at 10 GH/s | at 100 GH/s | +|---|---|---|---|---| +| 8 GB | NVIDIA RTX 4060 Ti 8 GB | 124,414 | 12,441 | 1,244.1 | +| 8 GB | NVIDIA RTX 4060 | 111,366 | 11,137 | 1,113.7 | +| 12 GB | NVIDIA RTX 3060 | 155,142 | 15,514 | 1,551.4 | +| 12 GB | NVIDIA RTX 4070 | 163,036 | 16,304 | 1,630.4 | +| 12 GB | NVIDIA RTX 5070 | 273,293 | 27,329 | 2,732.9 | +| 16 GB | NVIDIA RTX 4060 Ti 16 GB | 114,693 | 11,469 | 1,146.9 | +| 16 GB | AMD RX 9070 XT | 121,348 | 12,135 | 1,213.5 | +| 24 GB | NVIDIA RTX 3090 | 246,544 | 24,654 | 2,465.4 | +| 24 GB | NVIDIA RTX 4090 | 340,882 | 34,088 | 3,408.8 | +| 32 GB | NVIDIA RTX 5090 | 642,489 | 64,249 | 6,424.9 | +| Apple | Apple M5 Max (40 GPU cores) | 174,192 | 17,419 | 1,741.9 | +| Rig | 6 x NVIDIA RTX 4070 | 978,217 | 97,822 | 9,782.2 | + +## Electricity a day, and the electricity cost of one mined IGN + +Electricity a day = measured watts x 24 h x the tariff. The cost of one IGN divides that by the day-90 figure at 10 GH/s; at another network size it scales with the network (ten times the network, ten times the cost per coin). The three tariffs are the ones the mission named; USD 0.005 a kWh sits under every retail tariff on record and is a floor, not a tariff anyone pays (approximate, from memory of published tariffs). + +| Tier | Card | USD/day at 0.005/kWh | USD/day at 0.02/kWh | USD/day at 0.1/kWh | USD per IGN at 0.005/kWh | USD per IGN at 0.02/kWh | USD per IGN at 0.1/kWh | +|---|---|---|---|---|---|---|---| +| 8 GB | NVIDIA RTX 4060 Ti 8 GB | 0.0087 | 0.035 | 0.174 | 0.0000 | 0.0000 | 0.0000 | +| 8 GB | NVIDIA RTX 4060 | owed | owed | owed | owed | owed | owed | +| 12 GB | NVIDIA RTX 3060 | 0.012 | 0.050 | 0.249 | 0.0000 | 0.0000 | 0.0000 | +| 12 GB | NVIDIA RTX 4070 | 0.011 | 0.044 | 0.219 | 0.0000 | 0.0000 | 0.0000 | +| 12 GB | NVIDIA RTX 5070 | 0.016 | 0.066 | 0.329 | 0.0000 | 0.0000 | 0.0000 | +| 16 GB | NVIDIA RTX 4060 Ti 16 GB | 0.0087 | 0.035 | 0.174 | 0.0000 | 0.0000 | 0.0000 | +| 16 GB | AMD RX 9070 XT | 0.024 | 0.096 | 0.478 | 0.0000 | 0.0000 | 0.0000 | +| 24 GB | NVIDIA RTX 3090 | 0.027 | 0.110 | 0.549 | 0.0000 | 0.0000 | 0.0000 | +| 24 GB | NVIDIA RTX 4090 | 0.022 | 0.088 | 0.439 | 0.0000 | 0.0000 | 0.0000 | +| 32 GB | NVIDIA RTX 5090 | 0.031 | 0.124 | 0.620 | 0.0000 | 0.0000 | 0.0000 | +| Apple | Apple M5 Max (40 GPU cores) | owed | owed | owed | owed | owed | owed | +| Rig | 6 x NVIDIA RTX 4070 (a six-card rig of the 12 GB card, the rig's own power is six times the card's plus the host (the host is not measured here)) | 0.066 | 0.262 | 1.31 | 0.0000 | 0.0000 | 0.0000 | + +## What it means per tier, and what is being done + +- **One 8 GB card.** NVIDIA RTX 4060 Ti 8 GB: 19.1 MH/s at 73 W, 12,441 IGN a day at 10 GH/s after the ramp, electricity 0.174 a day at USD 0.10 a kWh. Mines; proves only core-only shards (docs/analysis/prover-tiers-real-cards.md). The record says this tier is first under power in every exit (docs/analysis/mission/past.md section 3): the glide never halves it overnight, and the card games when the income goes. Being done: the Cards screen shows the expected MH/s, W and blocks a day before the first share (mission item 6). +- **One 12 GB card.** NVIDIA RTX 5070: 41.9 MH/s at 137 W, 27,329 IGN a day at 10 GH/s after the ramp, electricity 0.329 a day at USD 0.10 a kWh. Mines and proves beside the miner on the patched server. Being done: the same Cards line; the proving tier sentence names the shard size the card takes. +- **One 16 GB card.** NVIDIA RTX 4060 Ti 16 GB: 17.6 MH/s at 72 W, 11,469 IGN a day at 10 GH/s after the ramp, electricity 0.174 a day at USD 0.10 a kWh. Mines and proves; the AMD row is the measured 9070 XT, 199 W for 18.6 MH/s, so per watt it is about a fifth of the NVIDIA cards of its tier and the electricity cost per coin is the highest in the table. Being done: the AMD read-width experiment (docs/plans/read-width.md); until it lands the AMD tier is told its per-watt figure before it buys. +- **One 24 or 32 GB card.** NVIDIA RTX 5090: 98.5 MH/s at 258 W, 64,249 IGN a day at 10 GH/s after the ramp, electricity 0.620 a day at USD 0.10 a kWh. The last GPU standing in every exit on record, and the only tier that proves the full shard beside the miner. Being done: the proving pool is the second income for this tier and the Earnings screen shows shards and IGN beside blocks. +- **A rig.** 6 x NVIDIA RTX 4070: cards times the card figure; the host's own draw is not measured and is owed. Rigs followed income across chains inside days on every chain in the record; Igneum expects no loyalty and pays staying keys a vote (30 days of blocks). +- **A pool user.** The same expected IGN minus the pool's fee, variance removed; pool-0 holds no balance and the member's own key is in every block it finds, so a pool's vote is the member's weight. Being done: the pool finished (mission item 11). +- **Windows, Linux, macOS.** The rates above are Linux containers (NVIDIA) and Windows (AMD); the Mac row is Metal on Apple silicon with no power reading. The same card on another operating system is within the driver's margin, not measured here: owed per platform at the testnet cut. + +## Owed + +- Intel discrete (Arc): no measurement; the tier is listed with no number until one exists +- RTX 4060 and Apple M5 Max wall power: the rows carry the rate and no electricity column +- every row at class v4 on the testnet cut: the rates above are the ten-field class of 6 October 2026 on rented boxes +- a rig host's own draw: a rig row is cards times the card figure, host excluded + +## Check + +`node tools/launch/income-tiers.mjs --check` (in `tools/ci/pre-push.sh`): the file equals the generator's output. `node --test tools/launch/income-tiers.test.mjs`: the schedule arithmetic against the fork's constants (100 MH/s on 100 GH/s after the ramp = 6,912 IGN a day, the figure in docs/analysis/horizon-2026-10.md; day 1 pays 10 percent; one month step pays 97.153 percent of the one before). diff --git a/tools/ci/launch-gates-check.mjs b/tools/ci/launch-gates-check.mjs new file mode 100644 index 000000000..4c75ecca5 --- /dev/null +++ b/tools/ci/launch-gates-check.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// The launch-pack gate (mission item 10, docs/analysis/mission/mission.md 2.10; the rule-row rule of CLAUDE.md, +// 6 October 2026: a rule row closes only with its check). Three things, each an exit 1 with the line: +// 1. every row of the "Launch gates" table in docs/plans/testnet-go.md has a non-empty Check cell, and every repository +// path the cell names in backticks exists (a gate whose check names a script that is not there is not a gate); +// 2. the text handed to the site lane (docs/plans/launch-pack.md between and ) +// and the public income table (docs/analysis/income-tiers.md) carry none of the served-page patterns of +// site/forbidden-strings.txt nor the export patterns of tools/ci/forbidden-strings.txt, and no em dash; +// 3. the eight regulatory sentences in the handoff are the eight of docs/analysis/mission/future.md section 8.3, each +// numbered, in order, none dropped (the handoff may rephrase; each must cite its number). +// node tools/ci/launch-gates-check.mjs # exit 1 on any failure +// node tools/ci/launch-gates-check.mjs --self-test # a gate row without a check fails; a handoff with the founder's name fails +import { existsSync, readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = process.env.LAUNCH_GATES_ROOT || path.join(HERE, '..', '..'); +const GO = 'docs/plans/testnet-go.md'; +const PACK = 'docs/plans/launch-pack.md'; +const INCOME = 'docs/analysis/income-tiers.md'; + +function patterns(root) { + const read = f => { try { return readFileSync(path.join(root, f), 'utf8'); } catch { return ''; } }; + const lines = [...read('site/forbidden-strings.txt').split('\n'), ...read('tools/ci/forbidden-strings.txt').split('\n')]; + return lines.map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => new RegExp(l)); +} + +export function gateRows(text) { + const start = text.indexOf('## Launch gates'); + if (start < 0) return null; + const section = text.slice(start); + const rows = []; + let header = null; + for (const line of section.split('\n')) { + if (!line.startsWith('|')) { if (header && rows.length) break; continue; } + const cells = line.split('|').slice(1, -1).map(c => c.trim()); + if (!header) { header = cells.map(c => c.toLowerCase()); continue; } + if (cells.every(c => /^-+$/.test(c))) continue; + const row = {}; header.forEach((h, i) => { row[h] = cells[i] || ''; }); + rows.push(row); + } + return rows; +} + +export function checkGates(root, problems) { + const text = readFileSync(path.join(root, GO), 'utf8'); + const rows = gateRows(text); + if (!rows) { problems.push(`${GO}: no "## Launch gates" section`); return; } + if (!rows.length) { problems.push(`${GO}: the Launch gates table has no rows`); return; } + for (const r of rows) { + const id = r.gate || r['#'] || '(no id)'; + const check = r.check || ''; + if (!check || /^(owed|none|tbd|n\/a)$/i.test(check)) problems.push(`${GO}: gate ${id} has no check`); + for (const m of check.matchAll(/`([^`]+)`/g)) { + const ref = m[1].split(/\s/)[0]; + if (/^(tools|docs|site|infra|packaging|proving|app)\//.test(ref) && !existsSync(path.join(root, ref))) problems.push(`${GO}: gate ${id} names ${ref}, which does not exist`); + } + } + return rows.length; +} + +export function handoff(root) { + const text = readFileSync(path.join(root, PACK), 'utf8'); + const a = text.indexOf(''), b = text.indexOf(''); + if (a < 0 || b < 0 || b < a) return null; + return text.slice(a, b); +} + +export function checkText(root, problems) { + const pats = patterns(root); + const h = handoff(root); + if (h === null) { problems.push(`${PACK}: no handoff block between and `); return; } + const bodies = [[PACK + ' (handoff)', h]]; + try { bodies.push([INCOME, readFileSync(path.join(root, INCOME), 'utf8')]); } catch { problems.push(`${INCOME}: missing`); } + for (const [name, body] of bodies) { + body.split('\n').forEach((line, i) => { + if (line.includes('\u2014')) problems.push(`${name}:${i + 1}: an em dash`); + for (const re of pats) if (re.test(line)) problems.push(`${name}:${i + 1}: forbidden pattern ${re.source}`); + }); + } + // the eight regulatory sentences, numbered 1 to 8 in order + const nums = [...h.matchAll(/\(8\.3 sentence (\d)[;)]/g)].map(m => Number(m[1])); + for (let k = 1; k <= 8; k++) if (!nums.includes(k)) problems.push(`${PACK} (handoff): regulatory sentence ${k} of future.md 8.3 is missing its "(8.3 sentence ${k})" mark`); + const ordered = nums.filter((v, i, arr) => arr.indexOf(v) === i); + if (ordered.join(',') !== '1,2,3,4,5,6,7,8') problems.push(`${PACK} (handoff): the eight sentences are not in order (${ordered.join(',')})`); + if (!/not legal advice; counsel is engaged/i.test(h)) problems.push(`${PACK} (handoff): the label "not legal advice; counsel is engaged" is missing`); +} + +function run(root) { + const problems = []; + const n = checkGates(root, problems); + checkText(root, problems); + return { problems, gates: n || 0 }; +} + +function selfTest() { + const fx = mkdtempSync(path.join(tmpdir(), 'launch-gates-')); + try { + for (const d of ['docs/plans', 'docs/analysis', 'site', 'tools/ci', 'tools/launch']) mkdirSync(path.join(fx, d), { recursive: true }); + writeFileSync(path.join(fx, 'site/forbidden-strings.txt'), 'Josh\n'); + writeFileSync(path.join(fx, 'tools/ci/forbidden-strings.txt'), '/Users/\n'); + writeFileSync(path.join(fx, 'tools/launch/x.mjs'), ''); + const sentences = Array.from({ length: 8 }, (_, i) => `${i + 1}. sentence (8.3 sentence ${i + 1})`).join('\n'); + const good = `# go\n\n## Launch gates\n\n| Gate | What | Check |\n|---|---|---|\n| G1 | a | \`tools/launch/x.mjs\` runs |\n`; + const goodPack = `\n${sentences}\nnot legal advice; counsel is engaged\n\n`; + writeFileSync(path.join(fx, GO), good); writeFileSync(path.join(fx, PACK), goodPack); writeFileSync(path.join(fx, INCOME), 'clean\n'); + let r = run(fx); + if (r.problems.length) throw new Error(`self-test: the clean fixture failed: ${r.problems.join('; ')}`); + // a gate without a check + writeFileSync(path.join(fx, GO), good + '| G2 | b | |\n'); + r = run(fx); if (!r.problems.some(p => /G2 has no check/.test(p))) throw new Error('self-test: a gate row without a check passed'); + // a check naming a missing script + writeFileSync(path.join(fx, GO), good + '| G3 | c | `tools/launch/missing.mjs` |\n'); + r = run(fx); if (!r.problems.some(p => /does not exist/.test(p))) throw new Error('self-test: a check naming a missing script passed'); + writeFileSync(path.join(fx, GO), good); + // the founder's name in the handoff, an em dash, a missing sentence + writeFileSync(path.join(fx, PACK), goodPack.replace('1. sentence', '1. Josh says')); + r = run(fx); if (!r.problems.some(p => /forbidden pattern Josh/.test(p))) throw new Error('self-test: the founder\'s name in the handoff passed'); + writeFileSync(path.join(fx, PACK), goodPack.replace('2. sentence', '2. a \u2014 dash')); + r = run(fx); if (!r.problems.some(p => /em dash/.test(p))) throw new Error('self-test: an em dash in the handoff passed'); + writeFileSync(path.join(fx, PACK), goodPack.replace('(8.3 sentence 5)', '')); + r = run(fx); if (!r.problems.some(p => /sentence 5 of future.md 8.3 is missing/.test(p))) throw new Error('self-test: a dropped regulatory sentence passed'); + process.stdout.write('self-test passed: a gate without a check, a missing script, the founder\'s name, an em dash and a dropped sentence each fail; the clean fixture passes\n'); + return 0; + } finally { rmSync(fx, { recursive: true, force: true }); } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.includes('--self-test')) process.exit(selfTest()); + const { problems, gates } = run(ROOT); + if (problems.length) { for (const p of problems) process.stderr.write(`${p}\n`); process.exit(1); } + process.stdout.write(`launch gates: ${gates} gate rows, each with its check; the handoff text and the income table carry no forbidden pattern; the eight regulatory sentences are present in order\n`); +} diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index cf0d63e5c..2cfb57242 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -85,6 +85,9 @@ tree_checks() { run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh' + run "launch gates: every row with its check, the handoff text clean (self-test, then the tree)" bash -c 'node tools/ci/launch-gates-check.mjs --self-test && node tools/ci/launch-gates-check.mjs' + run "income per tier: the public table equals its inputs, the schedule arithmetic" bash -c 'node tools/launch/income-tiers.mjs --check && node --test tools/launch/income-tiers.test.mjs' + run "hash-origin report: a known-finished day and a known-failed day" node --test tools/observer/hash-origin.test.mjs } gated_refs() { diff --git a/tools/launch/income-tiers.json b/tools/launch/income-tiers.json new file mode 100644 index 000000000..5cec96136 --- /dev/null +++ b/tools/launch/income-tiers.json @@ -0,0 +1,26 @@ +{ + "_about": "Inputs of docs/analysis/income-tiers.md (tools/launch/income-tiers.mjs). One row per measured card: the best measured mining rate of the Igneum miner alone on that card, with the wall power the same run read. Every row cites its measurement; a row with power null has its electricity column OWED, never estimated. No row is from memory. Tiers follow the consequences rule (CLAUDE.md, 5 October 2026): one 8 GB card, one 12 GB card, one 16 GB card, one 24 or 32 GB card, a rig, a pool user. Re-generate at every miner cut: the rates move with the program class.", + "measured_on": "6 October 2026, rented single-card Linux boxes (docs/analysis/prover-tiers-real-cards.md, bench log 'Prover tiers on real cards: the rented fleet, 6 October 2026'): the 0.3.12 miner on the ten-field override, the miner alone on the card, nvidia-smi power at the same second. The RX 9070 XT row is the telemetry run of 5 October 2026 on a Windows rig (bench log 'Power, heat, fans and clocks, measured'); the Apple row is the Metal bench of 4 October 2026 (site/miner-bench.json, class v4). The class v4 rates on the live devnet run lower than the ten-field rates on the same card (RTX 5090: 124.2 MH/s class v4 against 98.5 on the rented box under a different driver and container, docs/bench-log.md); the table is re-generated at the testnet cut from that class.", + "network_ghs": [1, 10, 100], + "kwh_prices_usd": [0.005, 0.02, 0.10], + "rows": [ + { "tier": "8 GB", "card": "NVIDIA RTX 4060 Ti 8 GB", "vram_gb": 8, "mh_s": 19.07, "watts": 72.6, "vendor": "nvidia", "source": "docs/analysis/prover-tiers-real-cards.md, RTX 4060 Ti 8 GB row" }, + { "tier": "8 GB", "card": "NVIDIA RTX 4060", "vram_gb": 8, "mh_s": 17.07, "watts": null, "vendor": "nvidia", "source": "docs/analysis/prover-tiers-real-cards.md, RTX 4060 row (the sampler read 0.0 W on that host: power OWED)" }, + { "tier": "12 GB", "card": "NVIDIA RTX 3060", "vram_gb": 12, "mh_s": 23.78, "watts": 103.7, "vendor": "nvidia", "source": "docs/analysis/prover-tiers-real-cards.md, RTX 3060 row" }, + { "tier": "12 GB", "card": "NVIDIA RTX 4070", "vram_gb": 12, "mh_s": 24.99, "watts": 91.1, "vendor": "nvidia", "source": "docs/analysis/prover-tiers-real-cards.md, RTX 4070 row" }, + { "tier": "12 GB", "card": "NVIDIA RTX 5070", "vram_gb": 12, "mh_s": 41.89, "watts": 137.0, "vendor": "nvidia", "source": "docs/analysis/prover-tiers-real-cards.md, RTX 5070 row" }, + { "tier": "16 GB", "card": "NVIDIA RTX 4060 Ti 16 GB", "vram_gb": 16, "mh_s": 17.58, "watts": 72.3, "vendor": "nvidia", "source": "docs/analysis/prover-tiers-real-cards.md, RTX 4060 Ti 16 GB row" }, + { "tier": "16 GB", "card": "AMD RX 9070 XT", "vram_gb": 16, "mh_s": 18.6, "watts": 199.0, "vendor": "amd", "source": "docs/bench-log.md, 'Power, heat, fans and clocks, measured' (5 October 2026): 18.6 to 19.2 MH/s, 199 W read by the ADLX helper at 100 percent busy" }, + { "tier": "24 GB", "card": "NVIDIA RTX 3090", "vram_gb": 24, "mh_s": 37.79, "watts": 228.8, "vendor": "nvidia", "source": "docs/analysis/prover-tiers-real-cards.md, RTX 3090 row" }, + { "tier": "24 GB", "card": "NVIDIA RTX 4090", "vram_gb": 24, "mh_s": 52.25, "watts": 183.1, "vendor": "nvidia", "source": "docs/analysis/prover-tiers-real-cards.md, RTX 4090 row" }, + { "tier": "32 GB", "card": "NVIDIA RTX 5090", "vram_gb": 32, "mh_s": 98.48, "watts": 258.2, "vendor": "nvidia", "source": "docs/analysis/prover-tiers-real-cards.md, RTX 5090 row (the rented box; 124.2 MH/s class v4 on a desktop, site/miner-bench.json)" }, + { "tier": "Apple", "card": "Apple M5 Max (40 GPU cores)", "vram_gb": null, "mh_s": 26.7, "watts": null, "vendor": "apple", "source": "site/miner-bench.json, Apple M5 Max class v4 row, 4 October 2026 (no power reading: OWED)" } + ], + "rig": { "cards": 6, "of": "NVIDIA RTX 4070", "note": "a six-card rig of the 12 GB card, the rig's own power is six times the card's plus the host (the host is not measured here)" }, + "owed": [ + "Intel discrete (Arc): no measurement; the tier is listed with no number until one exists", + "RTX 4060 and Apple M5 Max wall power: the rows carry the rate and no electricity column", + "every row at class v4 on the testnet cut: the rates above are the ten-field class of 6 October 2026 on rented boxes", + "a rig host's own draw: a rig row is cards times the card figure, host excluded" + ] +} diff --git a/tools/launch/income-tiers.mjs b/tools/launch/income-tiers.mjs new file mode 100644 index 000000000..f49fe2964 --- /dev/null +++ b/tools/launch/income-tiers.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Per-tier mining income on igneum-testnet-1's emission (mission item 10, docs/analysis/mission/mission.md 2.10; the +// consequences rule, CLAUDE.md 5 October 2026): what one card of each tier mines in IGN a day, what its electricity costs +// a day at three power prices, and the electricity cost of one mined IGN, from the measured bench rows in +// tools/launch/income-tiers.json and the genesis schedule EmissionSchedule::TESTNET_1 (node fork, consensus/core/src/ +// emission.rs: launch_rate 100 IGN per DAA second, ramp 7,776,000 s from 10 percent, step 2,629,800 s, decay +// 4,172,697,914 / 2^32 per step, tail 1 percent of supply a year; docs/plans/ledger-decisions.md, 7 October 2026, +// decision 1). The producer share is 80 percent of a block (spec 02 2.5, the 80/20 coinbase); the figures assume the +// key signs its checkpoints (decision 2: an unsigned key's tenth of the producer share goes to the proving pool). +// +// No price of the coin appears here. The dollar columns are electricity: a cost, from a measured watt figure and a +// tariff the reader picks. The three tariffs are the three the mission named (USD 0.005, 0.02 and 0.10 a kWh); the +// lowest is under any retail tariff on record and is kept because the mission named it. +// +// node tools/launch/income-tiers.mjs writes docs/analysis/income-tiers.md +// node tools/launch/income-tiers.mjs --check exit 1 when the file on disk is not what the inputs produce +// node tools/launch/income-tiers.mjs --json the computed rows as JSON on stdout +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +export const INPUTS = join(HERE, 'income-tiers.json'); +export const OUTPUT = join(ROOT, 'docs', 'analysis', 'income-tiers.md'); + +// EmissionSchedule::TESTNET_1, as the fork's emission.rs declares it (integer fields; UNIT = 10^8 sompi there, IGN here) +export const TESTNET_1 = { + launch_rate_ign_per_s: 100, + ramp_seconds: 90 * 86_400, // 7,776,000 + ramp_start_percent: 10, + step_seconds: 2_629_800, // YEAR_SECONDS / 12 + step_decay_q32: 4_172_697_914, // 2^(-1/24) in Q32: a two-year half-life + tail_bps_per_year: 100, +}; +export const PRODUCER_SHARE = 0.8; // spec 02 2.5: 80 percent of a block to the miner who found it, 20 to the proving pool +export const BLOCKS_PER_DAY = 86_400; // 1 block a second on the testnet (bps 1) + +/** emission.rs EmissionTable::build: the curve's rate at step k = launch_rate x decay^k (integer: x q32 >> 32 per step). */ +export function rateAtSeconds(seconds, s = TESTNET_1) { + const k = Math.floor(seconds / s.step_seconds); + // the fork multiplies a u128 by the q32 and shifts; in floating point the same product to well under a sompi + let rate = s.launch_rate_ign_per_s; + for (let i = 0; i < k; i++) rate = rate * s.step_decay_q32 / 2 ** 32; + return rate; +} + +/** emission.rs ramp: linear from ramp_start_percent at second 0 to 100 percent at ramp_seconds. */ +export function rampFactor(seconds, s = TESTNET_1) { + if (seconds >= s.ramp_seconds) return 1; + return (s.ramp_start_percent * s.ramp_seconds + (100 - s.ramp_start_percent) * seconds) / (100 * s.ramp_seconds); +} + +/** IGN a block paid to the producer at `seconds` since genesis (ramp and glide applied, the 80 percent share). */ +export function producerIgnPerBlock(seconds, s = TESTNET_1) { + return rateAtSeconds(seconds, s) * rampFactor(seconds, s) * PRODUCER_SHARE; +} + +/** IGN a day for a card at mh_s on a network of network_ghs, at `seconds` since genesis (expected value, solo). */ +export function ignPerDay(mh_s, network_ghs, seconds, s = TESTNET_1) { + const share = mh_s / (network_ghs * 1000); + return share * BLOCKS_PER_DAY * producerIgnPerBlock(seconds, s); +} + +/** Electricity a day in USD at `watts` and a tariff in USD per kWh. */ +export function electricityPerDay(watts, usdPerKwh) { return watts * 24 / 1000 * usdPerKwh; } + +export const MOMENTS = [ + { label: 'day 1', seconds: 0 }, + { label: 'day 30', seconds: 30 * 86_400 }, + { label: 'day 90 (ramp over)', seconds: 90 * 86_400 }, + { label: 'month 12', seconds: 365 * 86_400 }, + { label: 'year 2', seconds: 730 * 86_400 }, +]; + +const fmt = (x, d = 0) => Number(x).toLocaleString('en-GB', { minimumFractionDigits: d, maximumFractionDigits: d }); +const fmtMoney = x => (x < 0.01 ? x.toFixed(4) : x < 1 ? x.toFixed(3) : x.toFixed(2)); + +export function compute(inputs) { + const refSeconds = 90 * 86_400; // the ramp is over: the figure a miner keeps + const rows = inputs.rows.map(r => { + const perDay = {}; + for (const g of inputs.network_ghs) perDay[g] = ignPerDay(r.mh_s, g, refSeconds); + const elec = {}; + for (const p of inputs.kwh_prices_usd) elec[p] = r.watts === null ? null : electricityPerDay(r.watts, p); + const costPerIgn = {}; + const g10 = inputs.network_ghs.includes(10) ? 10 : inputs.network_ghs[0]; + for (const p of inputs.kwh_prices_usd) costPerIgn[p] = r.watts === null ? null : elec[p] / perDay[g10]; + return { ...r, mh_per_w: r.watts === null ? null : r.mh_s / r.watts, ign_per_day_day90: perDay, electricity_usd_per_day: elec, usd_per_ign_at_ref: costPerIgn, ref_network_ghs: g10 }; + }); + const moments = MOMENTS.map(m => ({ ...m, producer_ign_per_block: producerIgnPerBlock(m.seconds), factor_of_launch: producerIgnPerBlock(m.seconds) / (TESTNET_1.launch_rate_ign_per_s * PRODUCER_SHARE) })); + return { rows, moments, ref_seconds: refSeconds }; +} + +export function render(inputs, computed = compute(inputs)) { + const G = inputs.network_ghs, P = inputs.kwh_prices_usd; + const L = []; + L.push('# Mining income per tier on igneum-testnet-1'); + L.push(''); + L.push(`Generated by \`tools/launch/income-tiers.mjs\` from \`tools/launch/income-tiers.json\` (mission item 10, the consequences rule). Do not edit by hand: \`node tools/launch/income-tiers.mjs\` rewrites it and \`--check\` fails CI when the two disagree. Every rate and watt figure is a measurement with its source in the first table; nothing is estimated. Testnet coins have no value and the figures below say nothing about any price: the dollar columns are electricity, a cost the reader computes from a measured watt figure and a tariff they choose.`); + L.push(''); + L.push('## The schedule the figures use'); + L.push(''); + L.push('`EmissionSchedule::TESTNET_1` (the testnet genesis, decided 7 October 2026): 100 IGN a block at one block a second, a 90-day ramp from 10 percent, a monthly glide with a two-year half-life (each month pays 2^(-1/24) of the month before), then 1 percent of supply a year from about year 11.4. Of each block, 80 percent goes to the miner who found it and 20 percent to the proving pool. A miner whose key signs its checkpoints keeps the full 80 percent; an unsigned key gives a tenth of it to the proving pool.'); + L.push(''); + L.push('| Moment | IGN a block to the miner | Of the launch figure |'); + L.push('|---|---|---|'); + for (const m of computed.moments) L.push(`| ${m.label} | ${fmt(m.producer_ign_per_block, 2)} | ${fmt(m.factor_of_launch * 100, 1)}% |`); + L.push(''); + L.push('A solo card\'s expected income is its share of network hash times 86,400 blocks a day times the figure above. The tables use day 90, when the ramp is over. Before that, multiply by the ramp row; after that, by the glide row. A pool user gets the same expected amount minus the pool\'s fee (pool-0: 1 percent) with the variance taken out; the reference pool holds no balance and pays from the coinbase split.'); + L.push(''); + L.push('## The cards, measured'); + L.push(''); + L.push(`${inputs.measured_on}`); + L.push(''); + L.push('| Tier | Card | MH/s | W | MH/W | Source |'); + L.push('|---|---|---|---|---|---|'); + for (const r of computed.rows) L.push(`| ${r.tier} | ${r.card} | ${fmt(r.mh_s, 2)} | ${r.watts === null ? 'owed' : fmt(r.watts, 1)} | ${r.mh_per_w === null ? 'owed' : fmt(r.mh_per_w, 3)} | ${r.source} |`); + L.push(''); + L.push('## IGN a day per card, solo, after the ramp (day 90)'); + L.push(''); + L.push(`Three network sizes, because income is a share of the network and nobody knows the network before it exists. ${G.map(g => `${g} GH/s`).join(', ')} are reference sizes, not forecasts. The live network's estimate is on /live; divide by it.`); + L.push(''); + L.push(`| Tier | Card | ${G.map(g => `at ${g} GH/s`).join(' | ')} |`); + L.push(`|---|---|${G.map(() => '---').join('|')}|`); + for (const r of computed.rows) L.push(`| ${r.tier} | ${r.card} | ${G.map(g => fmt(r.ign_per_day_day90[g], g >= 100 ? 1 : 0)).join(' | ')} |`); + const rig = inputs.rig, rigRow = computed.rows.find(r => r.card === rig.of); + if (rigRow) L.push(`| Rig | ${rig.cards} x ${rig.of} | ${G.map(g => fmt(rigRow.ign_per_day_day90[g] * rig.cards, g >= 100 ? 1 : 0)).join(' | ')} |`); + L.push(''); + L.push('## Electricity a day, and the electricity cost of one mined IGN'); + L.push(''); + L.push(`Electricity a day = measured watts x 24 h x the tariff. The cost of one IGN divides that by the day-90 figure at ${computed.rows[0].ref_network_ghs} GH/s; at another network size it scales with the network (ten times the network, ten times the cost per coin). The three tariffs are the ones the mission named; USD 0.005 a kWh sits under every retail tariff on record and is a floor, not a tariff anyone pays (approximate, from memory of published tariffs).`); + L.push(''); + L.push(`| Tier | Card | ${P.map(p => `USD/day at ${p}/kWh`).join(' | ')} | ${P.map(p => `USD per IGN at ${p}/kWh`).join(' | ')} |`); + L.push(`|---|---|${P.map(() => '---').join('|')}|${P.map(() => '---').join('|')}|`); + for (const r of computed.rows) { + const e = P.map(p => r.electricity_usd_per_day[p] === null ? 'owed' : fmtMoney(r.electricity_usd_per_day[p])); + const c = P.map(p => r.usd_per_ign_at_ref[p] === null ? 'owed' : fmtMoney(r.usd_per_ign_at_ref[p])); + L.push(`| ${r.tier} | ${r.card} | ${e.join(' | ')} | ${c.join(' | ')} |`); + } + if (rigRow && rigRow.watts !== null) { + const e = P.map(p => fmtMoney(rigRow.electricity_usd_per_day[p] * rig.cards)); + const c = P.map(p => fmtMoney(rigRow.usd_per_ign_at_ref[p])); + L.push(`| Rig | ${rig.cards} x ${rig.of} (${rig.note}) | ${e.join(' | ')} | ${c.join(' | ')} |`); + } + L.push(''); + L.push('## What it means per tier, and what is being done'); + L.push(''); + const tier = t => computed.rows.filter(r => r.tier === t); + const best = rs => rs.filter(r => r.mh_per_w !== null).sort((a, b) => b.mh_per_w - a.mh_per_w)[0]; + const b8 = best(tier('8 GB')), b12 = best(tier('12 GB')), b16 = best(tier('16 GB')), b24 = best(tier('24 GB')), b32 = best(tier('32 GB')); + const line = (t, r, extra) => L.push(`- **${t}.** ${r ? `${r.card}: ${fmt(r.mh_s, 1)} MH/s at ${fmt(r.watts, 0)} W, ${fmt(r.ign_per_day_day90[10], 0)} IGN a day at 10 GH/s after the ramp, electricity ${fmtMoney(r.electricity_usd_per_day[0.10])} a day at USD 0.10 a kWh.` : ''} ${extra}`); + line('One 8 GB card', b8, 'Mines; proves only core-only shards (docs/analysis/prover-tiers-real-cards.md). The record says this tier is first under power in every exit (docs/analysis/mission/past.md section 3): the glide never halves it overnight, and the card games when the income goes. Being done: the Cards screen shows the expected MH/s, W and blocks a day before the first share (mission item 6).'); + line('One 12 GB card', b12, 'Mines and proves beside the miner on the patched server. Being done: the same Cards line; the proving tier sentence names the shard size the card takes.'); + line('One 16 GB card', b16, 'Mines and proves; the AMD row is the measured 9070 XT, 199 W for 18.6 MH/s, so per watt it is about a fifth of the NVIDIA cards of its tier and the electricity cost per coin is the highest in the table. Being done: the AMD read-width experiment (docs/plans/read-width.md); until it lands the AMD tier is told its per-watt figure before it buys.'); + line('One 24 or 32 GB card', b32 || b24, 'The last GPU standing in every exit on record, and the only tier that proves the full shard beside the miner. Being done: the proving pool is the second income for this tier and the Earnings screen shows shards and IGN beside blocks.'); + L.push(`- **A rig.** ${rig.cards} x ${rig.of}: cards times the card figure; the host's own draw is not measured and is owed. Rigs followed income across chains inside days on every chain in the record; Igneum expects no loyalty and pays staying keys a vote (30 days of blocks).`); + L.push('- **A pool user.** The same expected IGN minus the pool\'s fee, variance removed; pool-0 holds no balance and the member\'s own key is in every block it finds, so a pool\'s vote is the member\'s weight. Being done: the pool finished (mission item 11).'); + L.push('- **Windows, Linux, macOS.** The rates above are Linux containers (NVIDIA) and Windows (AMD); the Mac row is Metal on Apple silicon with no power reading. The same card on another operating system is within the driver\'s margin, not measured here: owed per platform at the testnet cut.'); + L.push(''); + L.push('## Owed'); + L.push(''); + for (const o of inputs.owed) L.push(`- ${o}`); + L.push(''); + L.push('## Check'); + L.push(''); + L.push('`node tools/launch/income-tiers.mjs --check` (in `tools/ci/pre-push.sh`): the file equals the generator\'s output. `node --test tools/launch/income-tiers.test.mjs`: the schedule arithmetic against the fork\'s constants (100 MH/s on 100 GH/s after the ramp = 6,912 IGN a day, the figure in docs/analysis/horizon-2026-10.md; day 1 pays 10 percent; one month step pays 97.153 percent of the one before).'); + L.push(''); + return L.join('\n'); +} + +export function loadInputs(path = INPUTS) { return JSON.parse(readFileSync(path, 'utf8')); } + +function main() { + const args = process.argv.slice(2); + const inputs = loadInputs(); + const text = render(inputs); + if (args.includes('--json')) { process.stdout.write(JSON.stringify(compute(inputs), null, 1) + '\n'); return 0; } + if (args.includes('--check')) { + let disk = null; + try { disk = readFileSync(OUTPUT, 'utf8'); } catch { disk = null; } + if (disk !== text) { process.stderr.write(`income tiers: ${OUTPUT} is not what tools/launch/income-tiers.json produces; run node tools/launch/income-tiers.mjs\n`); return 1; } + process.stdout.write('income tiers: the table matches its inputs\n'); return 0; + } + writeFileSync(OUTPUT, text); + process.stdout.write(`wrote ${OUTPUT} (${computed(inputs)} rows)\n`); + return 0; +} +function computed(inputs) { return inputs.rows.length; } + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) process.exit(main()); diff --git a/tools/launch/income-tiers.test.mjs b/tools/launch/income-tiers.test.mjs new file mode 100644 index 000000000..20d4b2aff --- /dev/null +++ b/tools/launch/income-tiers.test.mjs @@ -0,0 +1,35 @@ +// node --test tools/launch/income-tiers.test.mjs +// The schedule arithmetic against the fork's TESTNET_1 constants (one known-good case per rule, and one known-failed +// case: a changed input must change the rendered table, so --check cannot pass on a stale file). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ignPerDay, producerIgnPerBlock, rampFactor, rateAtSeconds, render, compute, loadInputs, TESTNET_1 } from './income-tiers.mjs'; + +test('100 MH/s on 100 GH/s after the ramp mines 6,912 IGN a day (docs/analysis/horizon-2026-10.md, section 1)', () => { + // the horizon figure was taken at the launch rate before any glide step; at day 90 two monthly steps have passed + const atLaunchRate = (100 / 100_000) * 86_400 * 100 * 0.8; + assert.equal(Math.round(atLaunchRate), 6912); + const day90 = ignPerDay(100, 100, 90 * 86_400); + assert.ok(day90 < atLaunchRate && day90 > atLaunchRate * 0.94, `day 90 is two glide steps under the launch figure: ${day90}`); +}); + +test('day 1 pays 10 percent, day 90 the whole rate, one month step pays 2^(-1/24) of the one before', () => { + assert.equal(rampFactor(0), 0.1); + assert.equal(rampFactor(TESTNET_1.ramp_seconds), 1); + assert.equal(rampFactor(TESTNET_1.ramp_seconds * 10), 1); + assert.equal(producerIgnPerBlock(0), 100 * 0.1 * 0.8); + const step1 = rateAtSeconds(TESTNET_1.step_seconds); + assert.ok(Math.abs(step1 - 100 * 2 ** (-1 / 24)) < 1e-6, `one step: ${step1}`); + assert.ok(Math.abs(rateAtSeconds(24 * TESTNET_1.step_seconds) - 50) < 1e-4, 'twenty-four steps halve the rate'); +}); + +test('every input row renders, and a changed rate changes the table (the stale-file case)', () => { + const inputs = loadInputs(); + const text = render(inputs); + for (const r of inputs.rows) assert.ok(text.includes(`| ${r.tier} | ${r.card} |`), `${r.card} is in the table`); + const c = compute(inputs); + for (const r of c.rows) { assert.ok(r.ign_per_day_day90[10] > 0); if (r.watts === null) assert.equal(r.electricity_usd_per_day[0.1], null); } + const changed = JSON.parse(JSON.stringify(inputs)); changed.rows[0].mh_s += 1; + assert.notEqual(render(changed), text); + assert.ok(!/\bPC [12]\b|Josh|Hetzner|\/Users\//.test(text), 'the public table carries no identity pattern'); +}); diff --git a/tools/observer/fixtures/hash-origin-day.json b/tools/observer/fixtures/hash-origin-day.json new file mode 100644 index 000000000..b3b559570 --- /dev/null +++ b/tools/observer/fixtures/hash-origin-day.json @@ -0,0 +1,35 @@ +{ + "_about": "A fabricated day for tools/observer/hash-origin.mjs --fixture: three project-fleet keys, a project pool with two member keys, an outside pool with two member keys, four outside solo keys. Keys are 64-hex of one repeated character; payout addresses are placeholders, not real addresses.", + "date": "2026-10-21", + "goDate": "2026-10-20", + "networkHps": 3000000000, + "fleetKeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "projectPools": ["igna-project-pool-0"], + "knownPools": ["igna-outside-pool"], + "blocks": [ + { "vote_key_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "evm_miner": "igna-fleet-a", "blue": 30000, "total": 30200 }, + { "vote_key_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "evm_miner": "igna-fleet-b", "blue": 12000, "total": 12050 }, + { "vote_key_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "evm_miner": "igna-project-pool-0", "blue": 9000, "total": 9010 }, + { "vote_key_hash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "evm_miner": "igna-project-pool-0", "blue": 6000, "total": 6010 }, + { "vote_key_hash": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "evm_miner": "igna-outside-pool", "blue": 8000, "total": 8020 }, + { "vote_key_hash": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "evm_miner": "igna-outside-pool", "blue": 7000, "total": 7000 }, + { "vote_key_hash": "1111111111111111111111111111111111111111111111111111111111111111", "evm_miner": "igna-solo-1", "blue": 5000, "total": 5000 }, + { "vote_key_hash": "2222222222222222222222222222222222222222222222222222222222222222", "evm_miner": "igna-solo-2", "blue": 3000, "total": 3000 }, + { "vote_key_hash": "3333333333333333333333333333333333333333333333333333333333333333", "evm_miner": "igna-solo-3", "blue": 900, "total": 900 }, + { "vote_key_hash": "4444444444444444444444444444444444444444444444444444444444444444", "evm_miner": "igna-solo-4", "blue": 60, "total": 61 }, + { "vote_key_hash": "5555555555555555555555555555555555555555555555555555555555555555", "evm_miner": "igna-solo-5", "blue": 0, "total": 3 } + ], + "history": [ + { "day": "2026-10-20", "vote_key_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "blue": 28000 }, + { "day": "2026-10-20", "vote_key_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "blue": 11000 }, + { "day": "2026-10-20", "vote_key_hash": "4444444444444444444444444444444444444444444444444444444444444444", "blue": 30 } + ], + "prevReports": [ + { "date": "2026-10-20", "network": { "hps": 1200000000 }, "gates": { "outside_pool_today": true } }, + { "date": "2026-10-19", "network": { "hps": 1100000000 }, "gates": { "outside_pool_today": false } } + ] +} diff --git a/tools/observer/hash-origin.mjs b/tools/observer/hash-origin.mjs new file mode 100644 index 000000000..4713b337c --- /dev/null +++ b/tools/observer/hash-origin.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +// The launch-week hash-origin report (mission item 10, docs/analysis/mission/mission.md 2.10; past.md verdict row 2: +// "secret hardware or fleets show up as an unexplained hashrate step in the first weeks", Nervos 2020, Iron Fish 2023, +// Kaspa 2023). Once a day, from the observer's tables, who found the blocks: how many keys, how much of the hash sits +// under pools, how much under the project's own fleet, the ten largest keys' share, and whether the network estimate +// stepped since yesterday. Daily for the first 90 days from the go; on Devnet 2 for the seven days before it. +// +// node tools/observer/hash-origin.mjs --dry [--prefix dn2_] [--go 2026-10-20] read the tables, print the report, write nothing +// node tools/observer/hash-origin.mjs --write [--post [--live]] [--prefix P] [--go D] persist the day (hash_origin_days, hash_origin_reports, +// live_state.hash_origin) and post to #numbers +// node tools/observer/hash-origin.mjs --fixture tools/observer/fixtures/hash-origin-day.json run on fabricated rows, no database +// node --test tools/observer/hash-origin.test.mjs one known-finished day, one known-failed day +// +// Reads: live_blocks (the 24 h window: vote_key_hash, evm_miner = the coinbase's payout address, color), live_state +// (the node's hash-rate estimate), miner_logs (the vote keys the project's own intake-reporting workers logged: "identity N +// '...' vote_key_hash=<64 hex>", the same lines tools/observer/vendor-share.mjs reads), and its own two tables. Writes only +// with --write: hash_origin_days (one row per key per day: the 30-day counts for the dust and independence rules), +// hash_origin_reports (the day's report as it was posted), and the jsonb column hash_origin on live_state. +// DATABASE_URL from ~/.config/igneum/env (never in the repo). The project's pool payout addresses come from +// IGNEUM_PROJECT_POOLS (comma-separated IGNA addresses) so the report can mark pool-0 as the project's. +// +// Definitions (ledger-decisions.md section 3, X5; CLAUDE.md finality rule: dust = 100 blocks in the 30-day window): +// a key "with a block" found at least one blue block in the window; "above dust" found 100 or more blue blocks in the +// last 30 days (its own table); a "shared payout" is a payout address that two or more keys mined to in the window, which +// is a pool OR one machine running several identities to one address (the devnet default until decision 13 of +// ledger-decisions.md lands), so a shared payout counts as a POOL only when it is attested: the project's own pools +// (IGNEUM_PROJECT_POOLS) or an outside pool whose operator published the signed key list X5 asks for +// (IGNEUM_KNOWN_POOLS); "fleet" is a key one of the project's intake-reporting workers logged (miner_logs) or one listed +// in the fleet registry's key file (IGNEUM_FLEET_KEYS_FILE, one 64-hex key per line: the rented boxes do not upload +// identity lines, so without the file the devnet reads its own fleet as outside); "outside" is everything else. +// The independent-key count (X5: autonomous system, machine fingerprint, pool attestation) needs two observer columns +// that do not exist yet; the report prints the keys above dust as the upper bound and says the columns are owed. +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const WINDOW_H = 24; +export const DUST_BLOCKS_30D = 100; +export const REPORT_DAYS = 90; +export const OUTSIDE_POOL_SHARE = 0.10; // reinvent.md 4.1 G-C4: a pool not run by the project with 10 percent of blocks for 7 days +export const OUTSIDE_POOL_DAYS = 7; +export const STEP_FLAG = 2; // the network estimate doubled (or halved) since yesterday: say so + +const pct = x => (x === null || x === undefined || Number.isNaN(x) ? 'n/a' : `${(x * 100).toFixed(1)}%`); +const n = x => Number(x).toLocaleString('en-GB'); +const id8 = k => String(k || '').slice(0, 8); + +/** + * The report from rows already read. + * date: 'YYYY-MM-DD' (the day the window ends, UTC) + * blocks: [{ vote_key_hash, evm_miner, blue, total }] for the window + * fleetKeys: iterable of 64-hex vote key hashes the project's workers logged + * projectPools: iterable of payout addresses the project runs + * history: [{ day, vote_key_hash, blue }] for the last 30 days (today's rows included or not; today is taken from blocks) + * networkHps: the node's estimate (H/s) or null + * prevReports: [report] for earlier days, newest first (for the step and the outside-pool streak) + * goDate: 'YYYY-MM-DD' of the testnet go, or null + */ +export function compute({ date, blocks = [], fleetKeys = [], projectPools = [], knownPools = [], history = [], networkHps = null, prevReports = [], goDate = null }) { + const fleet = new Set([...fleetKeys].map(k => String(k).toLowerCase())); + const pools = new Set([...projectPools].map(a => String(a).toLowerCase())); + const attested = new Set([...knownPools].map(a => String(a).toLowerCase())); + const rows = blocks.filter(b => b.vote_key_hash && Number(b.blue) > 0).map(b => ({ key: String(b.vote_key_hash).toLowerCase(), payout: b.evm_miner ? String(b.evm_miner).toLowerCase() : null, blue: Number(b.blue), total: Number(b.total) || Number(b.blue) })); + const total = rows.reduce((s, r) => s + r.blue, 0); + + // keys + const byKey = new Map(); + for (const r of rows) byKey.set(r.key, (byKey.get(r.key) || 0) + r.blue); + const keys = [...byKey.entries()].sort((a, b) => b[1] - a[1]); + const top10 = keys.slice(0, 10).reduce((s, [, b]) => s + b, 0); + const seenBefore = new Set(history.filter(h => h.day < date).map(h => String(h.vote_key_hash).toLowerCase())); + const newKeys = keys.filter(([k]) => !seenBefore.has(k)).length; + const thirty = new Map(); + for (const h of history) if (h.day < date) thirty.set(String(h.vote_key_hash).toLowerCase(), (thirty.get(String(h.vote_key_hash).toLowerCase()) || 0) + Number(h.blue)); + for (const [k, b] of keys) thirty.set(k, (thirty.get(k) || 0) + b); + const aboveDust = [...thirty.values()].filter(b => b >= DUST_BLOCKS_30D).length; + + // shared payouts: a payout address two or more keys mined to, or a listed pool; kind = project | attested | shared + const byPayout = new Map(); + for (const r of rows) { + if (!r.payout) continue; + const p = byPayout.get(r.payout) || { payout: r.payout, keys: new Set(), blue: 0 }; + p.keys.add(r.key); p.blue += r.blue; byPayout.set(r.payout, p); + } + const kindOf = a => (pools.has(a) ? 'project' : attested.has(a) ? 'attested' : 'shared'); + const poolRows = [...byPayout.values()].filter(p => p.keys.size >= 2 || pools.has(p.payout) || attested.has(p.payout)) + .map(p => ({ payout: p.payout, keys: p.keys.size, blue_blocks: p.blue, share: total ? p.blue / total : 0, kind: kindOf(p.payout), project: pools.has(p.payout) })) + .sort((a, b) => b.blue_blocks - a.blue_blocks); + const poolShare = poolRows.reduce((s, p) => s + p.share, 0); + const attestedRows = poolRows.filter(p => p.kind !== 'shared'); + const attestedShare = attestedRows.reduce((s, p) => s + p.share, 0); + + // fleet and outside + let fleetBlue = 0; const fleetSeen = new Set(); + for (const [k, b] of keys) if (fleet.has(k)) { fleetBlue += b; fleetSeen.add(k); } + const outsideKeys = keys.length - fleetSeen.size; + const outsideBlue = total - fleetBlue; + + // the step since yesterday + const prev = prevReports[0] || null; + const prevHps = prev && prev.network && prev.network.hps ? Number(prev.network.hps) : null; + const hps = networkHps === null || networkHps === undefined || !(Number(networkHps) > 0) ? null : Number(networkHps); + const ratio = hps !== null && prevHps !== null && prevHps > 0 ? hps / prevHps : null; + const stepFlag = ratio !== null && (ratio >= STEP_FLAG || ratio <= 1 / STEP_FLAG); + + // the outside-pool streak: days in a row (today included) with an ATTESTED non-project pool at or over 10 percent + const outsidePoolToday = poolRows.some(p => p.kind === 'attested' && p.share >= OUTSIDE_POOL_SHARE); + let streak = outsidePoolToday ? 1 : 0; + if (outsidePoolToday) for (const r of prevReports) { if (r.gates && r.gates.outside_pool_today) streak += 1; else break; } + + const dayOfGo = goDate ? Math.floor((Date.parse(`${date}T00:00:00Z`) - Date.parse(`${goDate}T00:00:00Z`)) / 86400e3) + 1 : null; + + const report = { + date, window_h: WINDOW_H, day_of_go: dayOfGo, report_days: REPORT_DAYS, + blue_blocks: total, + keys: { with_block: keys.length, new_today: newKeys, above_dust_30d: aboveDust, dust_rule: `${DUST_BLOCKS_30D} blue blocks in 30 days`, top10_share: total ? top10 / total : null, + largest: keys.slice(0, 10).map(([k, b]) => ({ id: id8(k), blue_blocks: b, share: total ? b / total : 0, fleet: fleet.has(k) })) }, + pools: { attested: attestedRows.length, attested_share: attestedShare, shared_payouts: poolRows.length, shared_share: poolShare, + rows: poolRows.map(p => ({ ...p, payout: `${p.payout.slice(0, 10)}…` })), + rule: 'a shared payout is an address two or more keys mined to in the window (a pool, or one machine with several identities); it counts as a pool when the project runs it or its operator published the signed key list' }, + fleet: { keys: fleetSeen.size, blue_blocks: fleetBlue, share: total ? fleetBlue / total : null, logged_keys: fleet.size }, + outside: { keys: outsideKeys, blue_blocks: outsideBlue, share: total ? outsideBlue / total : null }, + network: { hps, prev_hps: prevHps, ratio, step_flag: stepFlag }, + independent: { upper_bound: aboveDust, asn_and_fingerprint: 'owed: the observer does not store the autonomous system per announcing address or the machine fingerprint per key (ledger-decisions.md section 3); until it does the count above dust is an upper bound on N_ind' }, + gates: { + first_100_keys: keys.length >= 100, + first_block_outside_fleet: outsideBlue > 0, + outside_pool_today: outsidePoolToday, + outside_pool_days: streak, + first_outside_pool: streak >= OUTSIDE_POOL_DAYS, + first_1000_independent: false, first_1000_note: 'needs the two owed columns; never true from this report alone', + }, + }; + report.text = renderText(report); + return report; +} + +export function renderText(r) { + const L = []; + L.push(`Hash origin, ${r.date}${r.day_of_go !== null ? ` (day ${r.day_of_go} of ${r.report_days})` : ''}: ${n(r.blue_blocks)} blue blocks in ${r.window_h} h.`); + L.push(`Keys: ${n(r.keys.with_block)} found a block (${n(r.keys.new_today)} new today), ${n(r.keys.above_dust_30d)} above dust (${r.keys.dust_rule}). Ten largest keys: ${pct(r.keys.top10_share)} of blocks.`); + L.push(`Pools: ${n(r.pools.attested)} attested, ${pct(r.pools.attested_share)} of blocks; ${n(r.pools.shared_payouts)} shared payout addresses in all (pools or multi-key machines), ${pct(r.pools.shared_share)}${r.pools.rows.length ? ' (' + r.pools.rows.slice(0, 5).map(p => `${p.kind === 'project' ? 'project pool' : p.kind === 'attested' ? 'pool' : 'shared'} ${p.payout}: ${p.keys} keys, ${pct(p.share)}`).join('; ') + ')' : ''}.`); + L.push(`Project fleet: ${n(r.fleet.keys)} keys, ${pct(r.fleet.share)} of blocks. Outside the project: ${n(r.outside.keys)} keys, ${pct(r.outside.share)}.`); + if (r.network.hps !== null) L.push(`Network estimate: ${(r.network.hps / 1e9).toFixed(2)} GH/s${r.network.ratio !== null ? `, ${r.network.ratio.toFixed(2)}x yesterday` : ''}${r.network.step_flag ? '. A STEP: the estimate moved by 2x or more in a day; the keys that carry it are in the list above' : ''}.`); + else L.push('Network estimate: n/a.'); + L.push(`Independent keys (X5): at most ${n(r.independent.upper_bound)}; the autonomous-system and fingerprint columns are owed, so this is an upper bound.`); + const g = r.gates; + L.push(`Gates: first 100 keys ${g.first_100_keys ? 'PASSED' : 'not yet'}; first block from a card the project does not own ${g.first_block_outside_fleet ? 'PASSED' : 'not yet'}; first attested outside pool at 10 percent for 7 days ${g.first_outside_pool ? 'PASSED' : `${g.outside_pool_days} of ${OUTSIDE_POOL_DAYS} days`}.`); + return L.join('\n'); +} + +// ---- the queries (read-only; the writes are in persist()) ---- +const BLOCKS_SQL = T => ` + SELECT vote_key_hash, evm_miner, count(*) FILTER (WHERE color = 'blue')::int AS blue, count(*)::int AS total + FROM ${T}live_blocks WHERE received_at > now() - interval '${WINDOW_H} hours' AND vote_key_hash IS NOT NULL GROUP BY 1, 2`; +const STATE_SQL = T => `SELECT hashes_per_second_estimate FROM ${T}live_state WHERE id = 1`; +const FLEET_KEYS_SQL = ` + SELECT DISTINCT m[1] AS key FROM miner_logs, regexp_matches(lines, 'vote_key_hash=([0-9a-f]{64})', 'g') m + WHERE label LIKE 'miner-%' AND received_at > now() - interval '30 days'`; +const HISTORY_SQL = T => `SELECT day::text AS day, vote_key_hash, blue FROM ${T}hash_origin_days WHERE day > current_date - 30`; +const PREV_SQL = T => `SELECT report FROM ${T}hash_origin_reports WHERE day < $1::date ORDER BY day DESC LIMIT ${OUTSIDE_POOL_DAYS}`; + +export async function ensureTables(sql, T = '') { + await sql(`CREATE TABLE IF NOT EXISTS ${T}hash_origin_days (day date NOT NULL, vote_key_hash text NOT NULL, evm_miner text, blue int NOT NULL, PRIMARY KEY (day, vote_key_hash))`); + await sql(`CREATE TABLE IF NOT EXISTS ${T}hash_origin_reports (day date PRIMARY KEY, report jsonb NOT NULL, written_at timestamptz NOT NULL DEFAULT now())`); + await sql(`ALTER TABLE ${T}live_state ADD COLUMN IF NOT EXISTS hash_origin jsonb`); +} + +/** Reads the day. DDL (the two own tables, the live_state column) runs only when `write` is set: a --dry run writes nothing, and its own tables may not exist yet (then the 30-day history and the streak are empty, and the report says so by its numbers). */ +export function readKeyFile(file) { + if (!file) return []; + try { return readFileSync(file, 'utf8').split(/\r?\n/).map(l => l.trim().toLowerCase()).filter(l => /^[0-9a-f]{64}$/.test(l)); } catch { return []; } +} + +export async function readDay(sql, { T = '', date, goDate = null, projectPools = [], knownPools = [], extraFleetKeys = [], write = false }) { + if (write) await ensureTables(sql, T); + const own = q => sql(q[0], q[1]).catch(e => { if (/does not exist/i.test(String(e.message || e))) return []; throw e; }); + const [blocks, state, fleet, history, prev] = await Promise.all([sql(BLOCKS_SQL(T)), sql(STATE_SQL(T)), sql(FLEET_KEYS_SQL).catch(() => []), own([HISTORY_SQL(T), []]), own([PREV_SQL(T), [date]])]); + return compute({ date, blocks, fleetKeys: [...fleet.map(r => r.key), ...extraFleetKeys], projectPools, knownPools, history, networkHps: state[0] ? state[0].hashes_per_second_estimate : null, prevReports: prev.map(r => (typeof r.report === 'string' ? JSON.parse(r.report) : r.report)), goDate }); +} + +export async function persist(sql, T, report, blocks) { + for (const b of blocks) if (b.vote_key_hash && Number(b.blue) > 0) { + await sql(`INSERT INTO ${T}hash_origin_days (day, vote_key_hash, evm_miner, blue) VALUES ($1::date, $2, $3, $4) + ON CONFLICT (day, vote_key_hash) DO UPDATE SET blue = EXCLUDED.blue, evm_miner = EXCLUDED.evm_miner`, [report.date, String(b.vote_key_hash).toLowerCase(), b.evm_miner || null, Number(b.blue)]); + } + await sql(`INSERT INTO ${T}hash_origin_reports (day, report) VALUES ($1::date, $2::jsonb) ON CONFLICT (day) DO UPDATE SET report = EXCLUDED.report, written_at = now()`, [report.date, JSON.stringify(report)]); + await sql(`UPDATE ${T}live_state SET hash_origin = $1::jsonb WHERE id = 1`, [JSON.stringify(report)]); +} + +function readDatabaseUrl() { + if (process.env.DATABASE_URL) return process.env.DATABASE_URL; + try { + const env = readFileSync(path.join(homedir(), '.config', 'igneum', 'env'), 'utf8'); + const m = /^DATABASE_URL=(.+)$/m.exec(env); + if (m) return m[1].trim().replace(/^["']|["']$/g, ''); + } catch { /* no file */ } + throw new Error('DATABASE_URL is not set and ~/.config/igneum/env has none'); +} + +function makeSql(url) { + const host = new URL(url).hostname.replace('-pooler', ''); + return async (query, params = []) => { + const r = await fetch(`https://${host}/sql`, { method: 'POST', headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, params }) }); + const j = await r.json(); + if (!r.ok) throw new Error(j.message || JSON.stringify(j)); + return j.rows || []; + }; +} + +async function postReport(report, { live = false }) { + const hooks = await import('../community/discord-hooks.mjs'); + hooks.guardText(report.text, 'hash-origin'); + const poster = new hooks.Poster({ live }); + const payload = hooks.embed({ title: `Hash origin, ${report.date}`, url: `${hooks.SITE}/live`, description: report.text, footerNote: 'who found the blocks: keys, pools, the project fleet; daily for the first 90 days', now: new Date() }); + return poster.post('numbers', `hash-origin:${report.date}`, payload); +} + +async function main() { + const a = process.argv.slice(2); + const flag = k => a.includes(k); + const val = k => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; }; + const date = val('--date') || new Date().toISOString().slice(0, 10); + const goDate = val('--go'); + const T = (val('--prefix') || process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, ''); + const projectPools = (process.env.IGNEUM_PROJECT_POOLS || '').split(',').map(s => s.trim()).filter(Boolean); + const knownPools = (process.env.IGNEUM_KNOWN_POOLS || '').split(',').map(s => s.trim()).filter(Boolean); + const extraFleetKeys = readKeyFile(val('--fleet-keys') || process.env.IGNEUM_FLEET_KEYS_FILE); + if (flag('--fixture')) { + const fx = JSON.parse(readFileSync(val('--fixture'), 'utf8')); + const r = compute({ ...fx, date: fx.date || date, goDate: fx.goDate || goDate }); + process.stdout.write(`${r.text}\n`); + if (flag('--json')) process.stdout.write(JSON.stringify(r, null, 1) + '\n'); + return 0; + } + const sql = makeSql(readDatabaseUrl()); + const blocks = await sql(BLOCKS_SQL(T)); + const report = await readDay(sql, { T, date, goDate, projectPools, knownPools, extraFleetKeys, write: flag('--write') }); + process.stdout.write(`${report.text}\n`); + if (flag('--json')) process.stdout.write(JSON.stringify(report, null, 1) + '\n'); + if (flag('--write')) { + await persist(sql, T, report, blocks); + process.stderr.write(`wrote ${T}hash_origin_days (${blocks.length} rows), ${T}hash_origin_reports and live_state.hash_origin for ${date}\n`); + if (flag('--post')) { const r = await postReport(report, { live: flag('--live') }); process.stderr.write(`post: ${JSON.stringify(r)}\n`); } + } else if (flag('--post')) { process.stderr.write('--post needs --write (a posted day is a persisted day)\n'); return 2; } + return 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then(code => process.exit(code), e => { process.stderr.write(`${String(e.message || e)}\n`); process.exit(1); }); +} diff --git a/tools/observer/hash-origin.test.mjs b/tools/observer/hash-origin.test.mjs new file mode 100644 index 000000000..0a4d22865 --- /dev/null +++ b/tools/observer/hash-origin.test.mjs @@ -0,0 +1,72 @@ +// node --test tools/observer/hash-origin.test.mjs +// One known-finished day (the fixture: every share adds up, the outside block and the two pools are found, the step +// fires at 2.5x) and one known-failed day (an empty window passes no gate; a key with no blue block is not a key; a +// day without yesterday has no ratio), per the watcher rule of CLAUDE.md (4 October 2026). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compute, renderText, DUST_BLOCKS_30D, OUTSIDE_POOL_DAYS } from './hash-origin.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const fixture = () => JSON.parse(readFileSync(join(HERE, 'fixtures', 'hash-origin-day.json'), 'utf8')); +const close = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) < eps, `${a} ~ ${b}`); + +test('the known-finished day: counts, shares, pools, fleet, the step and the gates', () => { + const r = compute(fixture()); + assert.equal(r.blue_blocks, 80960); + assert.equal(r.keys.with_block, 10, 'the 0-blue key is not a key with a block'); + assert.equal(r.keys.new_today, 7, 'three keys mined yesterday; seven are new'); + // above dust over 30 days: a (58,000), b (23,000), c, d, e, f, 1, 2, 3 (900) = 9; key 4 has 90 < 100; key 5 none + assert.equal(r.keys.above_dust_30d, 9); + assert.equal(r.pools.attested, 2); assert.equal(r.pools.shared_payouts, 2); + const project = r.pools.rows.find(p => p.kind === 'project'), outside = r.pools.rows.find(p => p.kind === 'attested'); + assert.equal(project.keys, 2); assert.equal(project.blue_blocks, 15000); + assert.equal(outside.keys, 2); assert.equal(outside.blue_blocks, 15000); + close(r.pools.attested_share, 30000 / 80960); close(r.pools.shared_share, 30000 / 80960); + assert.equal(r.fleet.keys, 3); assert.equal(r.fleet.blue_blocks, 51000); close(r.fleet.share, 51000 / 80960); + assert.equal(r.outside.keys, 7); assert.equal(r.outside.blue_blocks, 29960); + close(r.fleet.share + r.outside.share, 1); + close(r.keys.top10_share, 1, 1e-9); + close(r.network.ratio, 2.5); assert.equal(r.network.step_flag, true); + assert.equal(r.gates.first_100_keys, false); + assert.equal(r.gates.first_block_outside_fleet, true); + assert.equal(r.gates.outside_pool_today, true, 'the outside pool holds 18.5 percent today'); + assert.equal(r.gates.outside_pool_days, 2, 'today plus yesterday; the day before breaks the streak'); + assert.equal(r.gates.first_outside_pool, false); + assert.equal(r.gates.first_1000_independent, false); + assert.equal(r.day_of_go, 2); + const t = renderText(r); + assert.ok(t.includes('A STEP'), 'the step is said'); + assert.ok(t.includes(`2 of ${OUTSIDE_POOL_DAYS} days`)); + assert.ok(!/[0-9a-f]{32}/i.test(t), 'no 32-hex token in the posted text (discord-hooks guard)'); + assert.ok(!/\b\d{1,3}(\.\d{1,3}){3}\b/.test(t), 'no IP address in the posted text'); +}); + +test('the known-failed day: an empty window passes nothing and claims nothing', () => { + const r = compute({ date: '2026-10-21', blocks: [{ vote_key_hash: 'a'.repeat(64), evm_miner: 'x', blue: 0, total: 5 }], fleetKeys: ['a'.repeat(64)] }); + assert.equal(r.blue_blocks, 0); assert.equal(r.keys.with_block, 0); assert.equal(r.keys.above_dust_30d, 0); + assert.equal(r.pools.attested, 0); assert.equal(r.pools.shared_payouts, 0); assert.equal(r.fleet.share, null); assert.equal(r.outside.share, null); + assert.equal(r.network.hps, null); assert.equal(r.network.ratio, null); assert.equal(r.network.step_flag, false); + for (const g of ['first_100_keys', 'first_block_outside_fleet', 'outside_pool_today', 'first_outside_pool', 'first_1000_independent']) assert.equal(r.gates[g], false, g); + assert.equal(r.day_of_go, null); + assert.ok(renderText(r).includes('not yet')); +}); + +test('the first-100 gate and the 7-day outside pool pass only on their numbers', () => { + const blocks = []; for (let i = 0; i < 100; i++) blocks.push({ vote_key_hash: i.toString(16).padStart(64, '0'), evm_miner: 'igna-outside-pool', blue: 10, total: 10 }); + const prev = []; for (let i = 0; i < OUTSIDE_POOL_DAYS - 1; i++) prev.push({ gates: { outside_pool_today: true }, network: { hps: 1e9 } }); + const unattested = compute({ date: '2026-11-01', blocks, networkHps: 1e9, prevReports: prev }); + assert.equal(unattested.gates.first_100_keys, true); + assert.equal(unattested.gates.outside_pool_today, false, 'a shared payout nobody attested is not a pool'); + assert.equal(unattested.pools.shared_payouts, 1); assert.equal(unattested.pools.attested, 0); + const r = compute({ date: '2026-11-01', blocks, networkHps: 1e9, prevReports: prev, knownPools: ['igna-outside-pool'] }); + assert.equal(r.gates.first_100_keys, true); + assert.equal(r.gates.first_outside_pool, true); + assert.equal(r.network.step_flag, false, '1.0x is not a step'); + const r99 = compute({ date: '2026-11-01', blocks: blocks.slice(1), prevReports: prev.slice(1), knownPools: ['igna-outside-pool'] }); + assert.equal(r99.gates.first_100_keys, false); + assert.equal(r99.gates.first_outside_pool, false); + assert.ok(DUST_BLOCKS_30D === 100); +});