igneum/tools/launch/income-tiers.mjs

191 lines
14 KiB
JavaScript

#!/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 (the coordinator, 7 October 2026, 11:xx UK): USD 0.05 (cheap industrial
// power), 0.10 (US retail) and 0.25 a kWh (UK retail at today's rate), all approximate as tariffs go.
//
// 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 ? Number(x.toPrecision(3)).toString() : 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: USD 0.05 a kWh is cheap industrial power, USD 0.10 is a US retail rate, USD 0.25 is a UK retail rate at today's level (each approximate; tariffs move, the watt figures do not).`);
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());