Launch pack tools: per-tier income table from the bench rows and TESTNET_1, the daily hash-origin report, the launch-gates check (mission item 10)
tools/launch/income-tiers.mjs renders docs/analysis/income-tiers.md (public) from tools/launch/income-tiers.json: eleven measured cards (the 6 October rented-card rows, the 9070 XT telemetry run, the M5 Max Metal bench, each with its source), IGN a day at 1, 10 and 100 GH/s after the ramp on EmissionSchedule::TESTNET_1 (100 IGN a block, 90-day ramp from 10 percent, monthly 2^(-1/24), the 80 percent producer share), electricity a day and per mined IGN at USD 0.005, 0.02 and 0.10 a kWh, a rig and a pool-user line, the consequences per tier, the owed rows; --check fails CI when the page and its inputs disagree; the test pins the arithmetic (6,912 IGN a day for 100 MH/s on 100 GH/s at the launch rate, day 1 at 10 percent, one step at 2^(-1/24)). tools/observer/hash-origin.mjs: once a day from the observer's tables, who found the blocks: keys with a block and above dust, attested pools against shared payout addresses (a multi-key machine is not a pool until its operator attests), the project fleet's share from the intake identity lines plus the fleet registry's key file, the ten largest keys, the 2x step since yesterday, the community gates (first 100 keys, first outside block, first attested outside pool at 10 percent for 7 days; the X5 count stays an upper bound until the two observer columns exist). --dry reads only; --write keeps hash_origin_days and hash_origin_reports and live_state.hash_origin; --post goes through the Discord poster's guard. Fixture, a known-finished and a known-failed day in the test. Ran read-only on the live devnet today: 113 keys, 0.76 GH/s, the fleet share wrong until the key file exists. tools/ci/launch-gates-check.mjs (in pre-push.sh with the two test lines): every row of the Launch gates table in testnet-go.md has a check and every backticked path exists; the site-lane handoff and the income table carry no served-page or export pattern and no em dash; the eight regulatory sentences are present in order under the label. Self-test fires on each. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b92a5fd4fa
commit
fef536abfa
9 changed files with 843 additions and 0 deletions
94
docs/analysis/income-tiers.md
Normal file
94
docs/analysis/income-tiers.md
Normal file
|
|
@ -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).
|
||||
135
tools/ci/launch-gates-check.mjs
Normal file
135
tools/ci/launch-gates-check.mjs
Normal file
|
|
@ -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 <!-- handoff:start --> and <!-- handoff:end -->)
|
||||
// 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('<!-- handoff:start -->'), b = text.indexOf('<!-- handoff:end -->');
|
||||
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 <!-- handoff:start --> and <!-- handoff:end -->`); 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 = `<!-- handoff:start -->\n${sentences}\nnot legal advice; counsel is engaged\n<!-- handoff:end -->\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`);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
26
tools/launch/income-tiers.json
Normal file
26
tools/launch/income-tiers.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
191
tools/launch/income-tiers.mjs
Normal file
191
tools/launch/income-tiers.mjs
Normal file
|
|
@ -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());
|
||||
35
tools/launch/income-tiers.test.mjs
Normal file
35
tools/launch/income-tiers.test.mjs
Normal file
|
|
@ -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');
|
||||
});
|
||||
35
tools/observer/fixtures/hash-origin-day.json
Normal file
35
tools/observer/fixtures/hash-origin-day.json
Normal file
|
|
@ -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 } }
|
||||
]
|
||||
}
|
||||
252
tools/observer/hash-origin.mjs
Normal file
252
tools/observer/hash-origin.mjs
Normal file
|
|
@ -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: <prefix>live_blocks (the 24 h window: vote_key_hash, evm_miner = the coinbase's payout address, color), <prefix>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: <prefix>hash_origin_days (one row per key per day: the 30-day counts for the dust and independence rules),
|
||||
// <prefix>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); });
|
||||
}
|
||||
72
tools/observer/hash-origin.test.mjs
Normal file
72
tools/observer/hash-origin.test.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in a new issue