Testnet go checklist: the proof retention row with the node side (dc141409, aea0ca5c) and the late-join gate, the proving pin row, the pre-existing m20 red, the fast-time line row; infra/fast-time/testnet-object.mjs (five nodes, chain-learnt addresses, the one-record-apart pairing, pid files, a stop on any fault), tn-prover-loop.mjs (a CPU prover beside node A: assigned shards, export, compressed proof, sign, submit), tn-late-join.mjs (the join harness with verification and proving v0 from genesis, no kill by name)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
5f3db40fe0
commit
0915a7a37e
4 changed files with 421 additions and 52 deletions
|
|
@ -113,9 +113,12 @@ Mac: 27 passed (the crate is not in the PC build inputs). The packager's self-te
|
|||
| 8 | The site's buttons: the download section points at 0.4.0, `site/wallet.html` carries `https://rpc.testnet.igneum.network` and chain id 4462 in place of the placeholder, the terms card (`#testnet-terms`) stays; `node site/build.mjs`, push to master (Vercel deploys) | the site agent | NOT DONE: the placeholder is still in `site/wallet.html` |
|
||||
| 9 | Announcement text | the project lead | PLACEHOLDER: "Igneum testnet-1 is open. Coins here have no value. Resets are announced seven days ahead. Download: igneum.network. RPC: rpc.testnet.igneum.network, chain id 4462." (the project lead's words replace this) |
|
||||
| 9a | The seeds' cut-over to the re-cut genesis (the runbook below): the re-cut binary on the three seeds, each data directory wiped (the 5 October chain at height 0 has nothing to keep), the digest line read back on each, the mesh re-formed | the infrastructure engineer, on the project lead's go | NOT DONE: binaries built, nothing deployed; the seeds hold the 5 October chain |
|
||||
| 9b | Proof retention: every node keeps proofs for the pruning window and the carried-proof rule applies only above the pruning point, so a node joining after the pool's horizon syncs from the pruning point (the coordinator's direction, 7 October 2026, 10:0x UK); the node lane designs it, the testnet lane gates it with a fresh join after the retention has passed on the fast-time network | the node lane, then this lane | NOT DONE: found 7 October 2026 by the 0.3.18 canary |
|
||||
| 9b | Proof retention: every node keeps proofs for the pruning window and the carried-proof rule applies only above the pruning point, so a node joining after the pool's horizon syncs from the pruning point (the coordinator's direction, 7 October 2026, 10:0x UK). Node side done: dc141409 (the rule, the IBD fetch and the relay retry apply from `proving_consensus_verify_daa` only) and aea0ca5c (`ProofArchive`: one file per proof under the exec db's `proofs/`, kept for the pruning window, served when the pool no longer holds the entry), both on `testnet-genesis-2-node`. The gate: `infra/fast-time/tn-late-join.mjs --join-after 700 --prover "node infra/fast-time/tn-prover-loop.mjs ..."` (a fresh node joins after the pool's 600-block window has passed, every proof it asks for from A's archive; the known-failed side is a binary before aea0ca5c, which stalls on "proofs this peer did not deliver in 20 s") | the node lane (done), then this lane | GATE NOT RUN YET (7 October 2026, 10:2x UK): binaries with the archive building |
|
||||
| 9e | The proving pin: `TESTNET_PARAMS` pins the shard and aggregator ids of `proving/igneum-prove/elf/manifest.json` as master holds it (shard `0x2b1a81cb...`, pinned 2026-10-05T16:20:38Z). The fin-proof lane's worktree carries a re-pin (shard `0x39db9d96...`, pinned 2026-10-07T08:03:43Z, not on master). If that re-pin merges before the go, the two ids and the digest move once more (one `print_testnet_object` run, this lane's) | whoever merges the re-pin tells this lane | OPEN |
|
||||
| 9f | A pre-existing red, not the object's: `processes::pruning_proof::igneum_m20_tests::witnesses_are_checked_in_epoch_order_under_their_own_seeds` fails under `--features igneum-pow` on the untouched `release-0.3.18-node` e69e8a39 (08:52 UK, box load under 10) and on the decimals base eec34ac3 (that lane's record); no lane's suite compiles the feature-gated test. Owner: the m20 tests' lane | the consensus engineer | OPEN, recorded |
|
||||
| 9c | Mission item 8: the genesis forward-compatibility fields (the sig_scheme byte, the W5 key-succession item, the cache rung on the ladder), lane `genesis-forward`; the hash and digest above move once more when they land | lane `genesis-forward`, then this lane re-cuts | NOT DONE |
|
||||
| 9d | Windows: `igneum-prove-host` beside every Windows node (verification from genesis); PC 1's node refuses to start without it | the release engineer (the 0.4.0 package) | NOT DONE: to check in the 0.4.0 Windows installer |
|
||||
| 9g | The fast-time line of the object on a devnet-suffix network (`infra/fast-time/testnet-object.mjs`, 5 nodes, four voting keys and one `--no-vote` key, the fast-time finality profile with leave delay 60): the start-up lines, class v4 at rung 0 through ten epochs, no ladder bits, one sink, the first lock, a signed leave accepted and the voter count dropping once the frozen table refreshes, the bonus split on the UTXO side for the silent key and the plain split for the voters, the bridge identity per miner over the whole chain (the chain block paid by its child's coinbase and credited in its own record). Runs so far on the fixed binaries: the facts of the first ten-minute runs are in the lane's report; the pass line is the run under way | this lane | RUNNING (10:2x UK) |
|
||||
| 10 | The first miner: one app on the testnet (PC 1 or PC 2 with the 0.4.0 build, or `igneumd --testnet` plus `igneum-miner --network testnet` by hand) produces block 1; the seeds relay it, `health.sh` shows blocks=1 on all three, `synced=True`; note the young-window join fault: a node that joins a chain younger than its finality window sees `synced=False` until blocks pass genesis, which is the no-blocks state, not a fault | the project lead says go, the miner-community lead starts it | NOT DONE: nothing mines until the word |
|
||||
| 11 | Watch: `NET=testnet ./health.sh --watch`, the RPC's `eth_blockNumber`, the DAA after 600 blocks (the launch difficulty `0x1d100000` is sized for a few hundred MH/s) | the infrastructure engineer | ready |
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// override-60x.json with every switch of infra/seed-nodes/testnet-object.json on from genesis, as the testnet carries
|
||||
// them (difficulty v3, the DAA-second finality rule, finality v3, the signed leave item, the signing bonus at 1,000 bps,
|
||||
// class v4 unconditional, the latency ladder active at rung 0, consensus proof verification under the pinned ids, fees
|
||||
// v1, EmissionSchedule::TESTNET_1), real CPU mining on every node (igneum-pow engine, CPU genesis bits), two voting keys
|
||||
// v1, EmissionSchedule::TESTNET_1), real CPU mining on every node (igneum-pow engine, CPU genesis bits), four voting keys
|
||||
// and one key that never votes (--no-vote). What the testnet itself cannot show inside a run (its weight window is 30
|
||||
// days) this run shows in minutes: the first lock, a silent producer paid the bonus split, and a signed leave.
|
||||
//
|
||||
|
|
@ -129,13 +129,14 @@ const override = `${TMP}/override.json`;
|
|||
writeFileSync(override, mergeOverrideText(baseText, { ...switches, latency_ladder: ladderList, fees: feesObject, emission: emissionObject, finality: finalityObject }));
|
||||
log(`object: ${OBJECT}; every switch from genesis; finality ${finalityObject}; ladder window ${LADDER_WINDOW}; emission ${emissionObject}; run ${SECS} s, leave at ${LEAVE_AT} s; expect ${EXPECT}`);
|
||||
|
||||
const LABELS = ['tn-voter-a', 'tn-voter-b', 'tn-silent'];
|
||||
const EVM = ['00000000000000000000000000000000000000a0', '00000000000000000000000000000000000000a1', '00000000000000000000000000000000000000a2'];
|
||||
const LABELS = ['tn-voter-a', 'tn-voter-b', 'tn-voter-c', 'tn-voter-d', 'tn-silent'];
|
||||
const EVM = ['00000000000000000000000000000000000000a0', '00000000000000000000000000000000000000a1', '00000000000000000000000000000000000000a2', '00000000000000000000000000000000000000a3', '00000000000000000000000000000000000000a4'];
|
||||
const N = LABELS.length, SILENT = N - 1, LEAVER = 1;
|
||||
// each miner's payout address as the miner itself prints it (its first log line names it); devAddress(label) is the
|
||||
// harness's own derivation and is not the miner's, so the chain's outputs are matched against the printed one
|
||||
let ADDR = LABELS.map(l => devAddress(l));
|
||||
function addressesFromMinerLogs() {
|
||||
return [0, 1, 2].map((i, k) => { const m = minerLog(i).join('\n').match(/igneum(?:dev|test)?:[a-z0-9]{20,}/); return m ? m[0] : ADDR[k]; });
|
||||
return LABELS.map((_, i) => { const m = minerLog(i).join('\n').match(/igneum(?:dev|test)?:[a-z0-9]{20,}/); return m ? m[0] : ADDR[i]; });
|
||||
}
|
||||
class Node {
|
||||
constructor(i, connect = []) {
|
||||
|
|
@ -152,6 +153,7 @@ class Node {
|
|||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
|
||||
started.push(this.proc);
|
||||
writeFileSync(`${TMP}/n${this.i}.pid`, String(this.proc.pid));
|
||||
await sleep(1500);
|
||||
this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`);
|
||||
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort} evm ${this.evmPort}`);
|
||||
|
|
@ -167,6 +169,7 @@ function miner(bin, argv, name, env = {}) {
|
|||
const out = openSync(`${TMP}/${name}.log`, 'a');
|
||||
const p = spawn(bin, argv, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
|
||||
started.push(p);
|
||||
writeFileSync(`${TMP}/${name}.pid`, String(p.pid));
|
||||
return p;
|
||||
}
|
||||
async function stopAll() {
|
||||
|
|
@ -176,14 +179,16 @@ async function stopAll() {
|
|||
}
|
||||
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
|
||||
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
||||
process.on('uncaughtException', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
||||
const minerLog = (i) => { try { return readFileSync(`${TMP}/cpu${i}.log`, 'utf8').split('\n'); } catch { return []; } };
|
||||
|
||||
const t0 = Date.now();
|
||||
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
||||
try {
|
||||
const n0 = await new Node(0).start();
|
||||
const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`]).start();
|
||||
const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`]).start();
|
||||
const nodes = [n0, n1, n2];
|
||||
const nodes = [n0];
|
||||
for (let i = 1; i < N; i++) nodes.push(await new Node(i, [`127.0.0.1:${n0.p2pPort}`]).start());
|
||||
const n1 = nodes[LEAVER];
|
||||
const START_LINES = {
|
||||
digest: /Consensus params digest: ([0-9a-f]{64})/,
|
||||
class_v4: /program class v4|class v4 from|Program class: v4|class_v4/i,
|
||||
|
|
@ -194,7 +199,7 @@ const START_LINES = {
|
|||
};
|
||||
for (const n of nodes) log(`n${n.i}: ${Object.entries(START_LINES).map(([k, re]) => `${k}=${n.grepLog(re).length > 0}`).join(' ')} | digest ${(n.grepLog(START_LINES.digest)[0] || '').replace(/^.*?digest: /, '').slice(0, 16)}`);
|
||||
// n0 and n1 vote (the key is the payout label's identity); n2 never votes: the silent key
|
||||
const miners = nodes.map((n, i) => miner(CPU_MINER, ['mine', n.grpc, '1', String(SECS), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', LABELS[i], '--evm-address', EVM[i], '--dev-fee', '0', '--status-secs', '30', '--stall-secs', '0', ...(i === 2 ? ['--no-vote'] : [])], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(DAY_MS) }));
|
||||
const miners = nodes.map((n, i) => miner(CPU_MINER, ['mine', n.grpc, '1', String(SECS), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', LABELS[i], '--evm-address', EVM[i], '--dev-fee', '0', '--status-secs', '30', '--stall-secs', '0', ...(i === SILENT ? ['--no-vote'] : [])], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(DAY_MS) }));
|
||||
const pay = devAddress('fast-time-testnet-object');
|
||||
|
||||
const epochs = new Map();
|
||||
|
|
@ -222,11 +227,11 @@ while (Date.now() - t0 < SECS * 1000) {
|
|||
if (leaveSent == null && Date.now() - t0 >= LEAVE_AT * 1000 && firstLock != null) {
|
||||
const before = await report(n1, 3);
|
||||
votersBeforeLeave = before?.checkpoints?.at(-1)?.voters ?? null;
|
||||
const out = spawnSync(CPU_MINER, ['leave', n1.grpc, LABELS[1]], { encoding: 'utf8', timeout: 20000 });
|
||||
const out = spawnSync(CPU_MINER, ['leave', n1.grpc, LABELS[LEAVER]], { encoding: 'utf8', timeout: 20000 });
|
||||
leaveSent = { at: +since(), daa, stdout: (out.stdout || '').trim().slice(0, 300), stderr: (out.stderr || '').trim().slice(0, 300) };
|
||||
leaveOutcome = /LEAVE key=/.test(out.stdout || '') ? 'accepted' : 'refused';
|
||||
log(`LEAVE by ${LABELS[1]} at daa ${daa}: ${leaveOutcome} (${leaveSent.stdout || leaveSent.stderr}); voters before ${votersBeforeLeave}; stopping its miner (a clean departure)`);
|
||||
try { miners[1].kill('SIGINT'); } catch { }
|
||||
log(`LEAVE by ${LABELS[LEAVER]} at daa ${daa}: ${leaveOutcome} (${leaveSent.stdout || leaveSent.stderr}); voters before ${votersBeforeLeave}; stopping its miner (a clean departure)`);
|
||||
try { miners[LEAVER].kill('SIGINT'); } catch { }
|
||||
}
|
||||
if (leaveSent != null && votersAfterLeave == null) {
|
||||
const after = await report(n0, 3);
|
||||
|
|
@ -265,60 +270,126 @@ async function allBlocks(n) {
|
|||
}
|
||||
let blocks = [];
|
||||
try { blocks = await allBlocks(n0); } catch (e) { log(`getBlocks: ${e.message}`); }
|
||||
ADDR = addressesFromMinerLogs();
|
||||
const chainBlocks = blocks.filter(b => b.chain && b.daa > 0).sort((a, b) => a.daa - b.daa);
|
||||
if (chainBlocks.length) log(`addresses: harness ${ADDR.map(a => a.slice(0, 24)).join(' ')}; first chain block outputs ${chainBlocks[0].outputs.map(o => `${o.address.slice(0, 24)}=${o.value}`).join(' ')}`);
|
||||
// the execution layer: segment n is chain block n; rewards per miner in wei
|
||||
const segments = [];
|
||||
const tip = Number(BigInt(await n0.evm('eth_blockNumber', []).catch(() => '0x0')));
|
||||
for (let i = 1; i <= tip; i++) { const s = await n0.evm('igneum_getSegment', [`0x${i.toString(16)}`]).catch(() => null); if (s) segments.push(s); }
|
||||
const segByHash = new Map(segments.map(s => [String(s.hash).replace(/^0x/, '').toLowerCase(), s]));
|
||||
const segByHash0 = new Map(segments.map(s => [String(s.hash).replace(/^0x/, '').toLowerCase(), s]));
|
||||
ADDR = addressesFromMinerLogs();
|
||||
const isPool = (o) => o.address === '' || o.address == null;
|
||||
const hashKey = (h) => String(h).replace(/^0x/, '').toLowerCase();
|
||||
const byHash = new Map(blocks.map(b => [hashKey(b.hash), b]));
|
||||
// What each coinbase pays and what each segment credits (the node lane's reading of 7 October 2026, confirmed on the
|
||||
// chain here): the coinbase of chain block b pays b's mergeset, which is chain block b-1 (its selected parent) plus the
|
||||
// side blocks b merges; the execution record of chain block b credits b ITSELF plus the side blues b merges. So a chain
|
||||
// block is paid by its child's coinbase and credited in its own segment, one record apart; a side blue is paid and
|
||||
// credited in the same chain block. Totals per miner over the chain agree exactly once the tip's own credit (not yet
|
||||
// paid by a child) is set aside.
|
||||
const chainIndex = new Map(chainBlocks.map((b, i) => [hashKey(b.hash), i]));
|
||||
const sidesOf = (seg, selfHash) => (seg?.mergeset || []).filter(m => hashKey(m.hash) !== selfHash);
|
||||
const segHoldsItself = chainBlocks.filter(b => { const seg = segByHash0.get(hashKey(b.hash)); return seg && (seg.mergeset || []).some(m => hashKey(m.hash) === hashKey(b.hash)); }).length;
|
||||
log(`segments: ${segByHash0.size}; chain blocks whose own segment lists them in its mergeset: ${segHoldsItself} of ${chainBlocks.length}`);
|
||||
// the TESTNET_1 schedule at the devnet's unit inside the ramp (the run is minutes long; the first glide step is a month away)
|
||||
const RAMP = 7_776_000n, START = 10n, FULL = 10_000_000_000n;
|
||||
const subsidyAt = (daa) => { const sDaa = BigInt(daa); return sDaa >= RAMP ? FULL : FULL * (START * RAMP + (100n - START) * sDaa) / (100n * RAMP); };
|
||||
const poolShare = (a) => (a / 100n) * 20n + (a % 100n) * 20n / 100n;
|
||||
const splitOf = (a, silent) => { const producerShare = a - poolShare(a); let producer = producerShare; let pool = poolShare(a); if (silent) { const bonus = producerShare * 1000n / 10000n; producer -= bonus > producer ? producer : bonus; pool += bonus; } return [producer, pool]; };
|
||||
// learn each miner's UTXO address from the chain: a chain block b whose coinbase has one producer output and whose
|
||||
// mergeset is its selected parent alone (segment(b) lists no side block) pays chain block b-1, whose miner is the one
|
||||
// reward of segment(b-1) when that segment has no sides either
|
||||
{
|
||||
const learnt = new Map();
|
||||
for (let i = 1; i < chainBlocks.length; i++) {
|
||||
const b = chainBlocks[i], parent = chainBlocks[i - 1];
|
||||
const seg = segByHash0.get(hashKey(b.hash)), segParent = segByHash0.get(hashKey(parent.hash));
|
||||
if (!seg || !segParent) continue;
|
||||
if (sidesOf(seg, hashKey(b.hash)).length !== 0 || sidesOf(segParent, hashKey(parent.hash)).length !== 0) continue;
|
||||
const producers = b.outputs.filter(o => !isPool(o));
|
||||
const rewards = segParent.rewards || [];
|
||||
if (producers.length !== 1 || rewards.length !== 1) continue;
|
||||
const k = EVM.indexOf(hashKey(rewards[0].miner));
|
||||
if (k >= 0 && !learnt.has(k)) learnt.set(k, producers[0].address);
|
||||
}
|
||||
for (const [i, a] of learnt) ADDR[i] = a;
|
||||
log(`learnt addresses from the chain: ${[...learnt.entries()].map(([i, a]) => `${LABELS[i]}=${a.slice(0, 24)}`).join(' ')} (${learnt.size} of ${N})`);
|
||||
}
|
||||
const lockDaa = firstLock?.daa ?? Infinity;
|
||||
// per chain block after the first lock: the UTXO outputs to each miner's address against the segment's rewards to its EVM address
|
||||
// 1. the identity per miner over the whole chain: UTXO payments x 10^10 against execution credits, the tip's own
|
||||
// credit set aside (its child's coinbase does not exist yet)
|
||||
const tipBlock = chainBlocks.at(-1);
|
||||
const tipSeg = tipBlock ? segByHash0.get(hashKey(tipBlock.hash)) : null;
|
||||
const tipOwnCredit = (k) => { if (!tipSeg) return 0n; const blues = (tipSeg.mergeset || []).filter(m => m.blue); const self = blues.findIndex(m => hashKey(m.hash) === hashKey(tipBlock.hash)); const r = (tipSeg.rewards || [])[self]; return r && hashKey(r.miner) === EVM[k] ? BigInt(r.wei) : 0n; };
|
||||
const bridgeRows = [];
|
||||
for (let k = 0; k < N; k++) {
|
||||
const utxo = chainBlocks.reduce((s, b) => s + b.outputs.filter(o => o.address === ADDR[k]).reduce((t, o) => t + o.value, 0n), 0n);
|
||||
const exec = [...segByHash0.values()].reduce((s, seg) => s + (seg.rewards || []).filter(r => hashKey(r.miner) === EVM[k]).reduce((t, r) => t + BigInt(r.wei), 0n), 0n);
|
||||
const execPaid = exec - tipOwnCredit(k);
|
||||
bridgeRows.push({ miner: LABELS[k], utxo_sompi: String(utxo), exec_wei: String(exec), exec_wei_less_tip: String(execPaid), identity: utxo * WEI_PER_SOMPI === execPaid, blocks_paid: chainBlocks.reduce((n, b) => n + b.outputs.filter(o => o.address === ADDR[k]).length, 0) });
|
||||
}
|
||||
const bridgeIdentityVoters = bridgeRows.filter(r => r.miner !== LABELS[SILENT]).every(r => r.identity && r.blocks_paid > 0);
|
||||
// 2. the bonus on the UTXO side: every coinbase output after the first lock, matched to the block it pays (chain b-1
|
||||
// or a side blue of b, by miner, when that miner has exactly one blue block in the mergeset), priced at that block's
|
||||
// own DAA: a silent key's block is paid the bonus split, a voting key's the plain 80 percent
|
||||
const bonusRows = [];
|
||||
for (const b of chainBlocks) {
|
||||
for (let i = 1; i < chainBlocks.length; i++) {
|
||||
const b = chainBlocks[i];
|
||||
if (b.daa <= lockDaa) continue;
|
||||
const seg = segByHash.get(b.hash.replace(/^0x/, '').toLowerCase());
|
||||
const seg = segByHash0.get(hashKey(b.hash));
|
||||
if (!seg) continue;
|
||||
const subsidyOf = (hash) => { const m = (seg.mergeset || []).find(m => String(m.hash).replace(/^0x/, '').toLowerCase() === hash); return m; };
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const utxo = b.outputs.filter(o => o.address === ADDR[i]).reduce((s, o) => s + o.value, 0n);
|
||||
const exec = (seg.rewards || []).filter(r => String(r.miner).replace(/^0x/, '').toLowerCase() === EVM[i]).reduce((s, r) => s + BigInt(r.wei), 0n);
|
||||
if (utxo === 0n && exec === 0n) continue;
|
||||
bridgeRows.push({ chain_daa: b.daa, chain: b.hash.slice(0, 12), miner: LABELS[i], utxo_sompi: String(utxo), exec_wei: String(exec), identity: utxo * WEI_PER_SOMPI === exec });
|
||||
// the split: the merged blocks of this miner in the mergeset, their subsidies from the exec rewards' own count
|
||||
const merged = (seg.rewards || []).filter(r => String(r.miner).replace(/^0x/, '').toLowerCase() === EVM[i]).length;
|
||||
if (merged === 1 && utxo > 0n) {
|
||||
// one merged block of this key: its subsidy is the table's at the merged block's DAA; the UTXO producer share tells the split
|
||||
const m = (seg.mergeset || []).find(mm => (mm.miner ?? '').toString().toLowerCase().endsWith(EVM[i])) ?? null;
|
||||
const pool = b.outputs.filter(o => o.address !== ADDR[0] && o.address !== ADDR[1] && o.address !== ADDR[2]).reduce((s, o) => s + o.value, 0n);
|
||||
bonusRows.push({ chain_daa: b.daa, miner: LABELS[i], silent_key: i === 2, utxo_producer_sompi: String(utxo), exec_wei: String(exec), merged_daa: m?.daaScore ?? null });
|
||||
}
|
||||
const paid = [{ hash: hashKey(chainBlocks[i - 1].hash), blue: true }, ...sidesOf(seg, hashKey(b.hash)).map(m => ({ hash: hashKey(m.hash), blue: !!m.blue }))];
|
||||
const paidBlocks = paid.map(p => ({ ...p, block: byHash.get(p.hash) })).filter(p => p.block);
|
||||
for (let k = 0; k < N; k++) {
|
||||
const mine = paidBlocks.filter(p => p.block.outputs !== undefined && minerOf(p.block) === k);
|
||||
if (mine.length !== 1 || !mine[0].blue) continue;
|
||||
const out = b.outputs.filter(o => o.address === ADDR[k]);
|
||||
if (out.length !== 1) continue;
|
||||
const [expectVoting] = splitOf(subsidyAt(mine[0].block.daa), false);
|
||||
const [expectSilent] = splitOf(subsidyAt(mine[0].block.daa), true);
|
||||
bonusRows.push({ chain_daa: b.daa, paid_block_daa: mine[0].block.daa, miner: LABELS[k], silent_key: k === SILENT, utxo_producer_sompi: String(out[0].value), expect_voting: String(expectVoting), expect_silent: String(expectSilent) });
|
||||
}
|
||||
}
|
||||
function minerOf(block) { for (let k = 0; k < N; k++) if (block.outputs.some(() => false)) return -1; return block.minerIndex ?? -1; }
|
||||
// a block's miner: the vote key hash is not in getBlocks' verbose data here, so take it from the segment that credits
|
||||
// the block (its own segment for a chain block, the merging chain block's segment for a side blue)
|
||||
for (const b of blocks) {
|
||||
const own = segByHash0.get(hashKey(b.hash));
|
||||
let miner = null;
|
||||
if (own) { const blues = (own.mergeset || []).filter(m => m.blue); const self = blues.findIndex(m => hashKey(m.hash) === hashKey(b.hash)); const r = (own.rewards || [])[self]; if (r) miner = hashKey(r.miner); }
|
||||
if (miner == null) for (const seg of segByHash0.values()) { const blues = (seg.mergeset || []).filter(m => m.blue); const j = blues.findIndex(m => hashKey(m.hash) === hashKey(b.hash)); if (j >= 0) { const r = (seg.rewards || [])[j]; if (r) { miner = hashKey(r.miner); break; } } }
|
||||
b.minerIndex = miner == null ? -1 : EVM.indexOf(miner);
|
||||
}
|
||||
// the bonus rows were built before minerIndex existed: build them again now that every block knows its miner
|
||||
bonusRows.length = 0;
|
||||
for (let i = 1; i < chainBlocks.length; i++) {
|
||||
const b = chainBlocks[i];
|
||||
if (b.daa <= lockDaa) continue;
|
||||
const seg = segByHash0.get(hashKey(b.hash));
|
||||
if (!seg) continue;
|
||||
const paid = [{ hash: hashKey(chainBlocks[i - 1].hash), blue: true }, ...sidesOf(seg, hashKey(b.hash)).map(m => ({ hash: hashKey(m.hash), blue: !!m.blue }))];
|
||||
const paidBlocks = paid.map(p => ({ ...p, block: byHash.get(p.hash) })).filter(p => p.block);
|
||||
for (let k = 0; k < N; k++) {
|
||||
const mine = paidBlocks.filter(p => p.block.minerIndex === k);
|
||||
if (mine.length !== 1 || !mine[0].blue) continue;
|
||||
const out = b.outputs.filter(o => o.address === ADDR[k]);
|
||||
if (out.length !== 1) continue;
|
||||
const [expectVoting] = splitOf(subsidyAt(mine[0].block.daa), false);
|
||||
const [expectSilent] = splitOf(subsidyAt(mine[0].block.daa), true);
|
||||
bonusRows.push({ chain_daa: b.daa, paid_block_daa: mine[0].block.daa, miner: LABELS[k], silent_key: k === SILENT, utxo_producer_sompi: String(out[0].value), expect_voting: String(expectVoting), expect_silent: String(expectSilent) });
|
||||
}
|
||||
}
|
||||
// the bonus: a silent key's producer output is 72 percent of what a voting key's would be at the same subsidy; the two
|
||||
// voting keys are the control at 80. Compare each silent row's UTXO producer share with the exec credit (80 percent by
|
||||
// the executor's rule before the fix): under the bonus utxo = exec / 10^10 * 0.9 exactly (the bonus is 10 percent of the
|
||||
// producer share), and under the fixed executor utxo * 10^10 = exec.
|
||||
const silentRows = bonusRows.filter(r => r.silent_key);
|
||||
const votingRows = bonusRows.filter(r => !r.silent_key);
|
||||
const tenth = (x) => x / 10n;
|
||||
const bonusPaidOnUtxo = silentRows.length > 0 && silentRows.every(r => {
|
||||
const utxo = BigInt(r.utxo_producer_sompi), execSompi = BigInt(r.exec_wei) / WEI_PER_SOMPI;
|
||||
// either the executor still pays the full share (utxo = 90 percent of it) or it pays the coinbase's split (equal)
|
||||
return utxo === execSompi - tenth(execSompi) || (utxo === execSompi && utxo < BigInt(votingRows[0]?.utxo_producer_sompi ?? '0'));
|
||||
});
|
||||
const silentPaidLessThanVoters = silentRows.length > 0 && votingRows.length > 0 && silentRows.every(r => BigInt(r.utxo_producer_sompi) < BigInt(votingRows.reduce((m, v) => BigInt(v.utxo_producer_sompi) > BigInt(m) ? v.utxo_producer_sompi : m, '0')));
|
||||
const bridgeIdentity = bridgeRows.length > 0 && bridgeRows.every(r => r.identity);
|
||||
const bridgeIdentityVoters = bridgeRows.filter(r => r.miner !== LABELS[2]).length > 0 && bridgeRows.filter(r => r.miner !== LABELS[2]).every(r => r.identity);
|
||||
const bridgeIdentitySilent = bridgeRows.filter(r => r.miner === LABELS[2]).length > 0 && bridgeRows.filter(r => r.miner === LABELS[2]).every(r => r.identity);
|
||||
const bonusPaidOnUtxo = silentRows.length > 0 && silentRows.every(r => r.utxo_producer_sompi === r.expect_silent);
|
||||
const votersPaidPlain = votingRows.length > 0 && votingRows.every(r => r.utxo_producer_sompi === r.expect_voting);
|
||||
const silentPaidLessThanVoters = bonusPaidOnUtxo && votersPaidPlain;
|
||||
const bridgeIdentity = bridgeRows.every(r => r.identity);
|
||||
|
||||
const bridgeIdentitySilent = bridgeRows.filter(r => r.miner === LABELS[SILENT]).every(r => r.identity && r.blocks_paid > 0);
|
||||
|
||||
const objectBytes = blocks.filter(b => b.daa > 0).reduce((m, b) => { const v = (b.version >> 8) & 0x3f; m[v] = (m[v] || 0) + 1; return m; }, {});
|
||||
const ladderBits = blocks.filter(b => b.daa > 0).reduce((m, b) => { const k = (b.version & 0x8000) ? 'up' : (b.version & 0x4000) ? 'down' : 'none'; m[k] = (m[k] || 0) + 1; return m; }, {});
|
||||
const sinks = dag.map(d => String(d.sink || '?').slice(0, 16));
|
||||
const counts = dag.map(d => d.blockCount ?? '?');
|
||||
const accepted = [0, 1, 2].map(i => minerLog(i).filter(l => /ACCEPTED block/.test(l)).length);
|
||||
const accepted = LABELS.map((_, i) => minerLog(i).filter(l => /ACCEPTED block/.test(l)).length);
|
||||
const rejectedNode = nodes.map(n => n.grepLog(/PoW rejected|Rejected block|rejected block/i).length);
|
||||
const finalReports = await Promise.all(nodes.map(n => report(n, 3)));
|
||||
const checks = {
|
||||
|
|
@ -331,13 +402,14 @@ const checks = {
|
|||
first_lock_on_every_node: firstLock != null && finalReports.every(r => r && r.latestLockedIndex > 0),
|
||||
silent_key_mined_after_the_lock: silentRows.length > 0,
|
||||
bonus_paid_on_the_utxo_side: silentPaidLessThanVoters && bonusPaidOnUtxo,
|
||||
voters_paid_the_plain_split_on_the_utxo_side: votersPaidPlain,
|
||||
bridge_identity_for_voting_keys: bridgeIdentityVoters,
|
||||
bridge_identity_for_the_silent_key: bridgeIdentitySilent,
|
||||
leave_accepted: leaveOutcome === 'accepted',
|
||||
leave_took_effect_within_delay: leaveSeenAt != null && leaveSent != null && (leaveSeenAt.daa - leaveSent.daa) <= LEAVE_DELAY + 30 + 5,
|
||||
leave_took_effect_within_delay_and_a_window: leaveSeenAt != null && leaveSent != null && (leaveSeenAt.daa - leaveSent.daa) <= LEAVE_DELAY + 120 + 30 + 5,
|
||||
// the point of the leave item: a clean departure never holds finality for a window; the pause after the leave, if any,
|
||||
// ends inside the delay plus one checkpoint, never a full window (120 DAA at fast time)
|
||||
no_window_pause_after_the_leave: leaveSent != null && (inactiveAfterLeave.length === 0 || (Math.max(...inactiveAfterLeave.map(x => x.daa)) - Math.min(...inactiveAfterLeave.map(x => x.daa))) < 120),
|
||||
no_window_pause_after_the_leave_took_effect: leaveSeenAt != null && !inactiveAfterLeave.some(x => x.daa > leaveSeenAt.daa + 30),
|
||||
finality_active_at_the_end: finalReports.every(r => r && r.finalityActive === true),
|
||||
};
|
||||
let pass;
|
||||
|
|
@ -356,8 +428,13 @@ const summary = {
|
|||
final_reports: finalReports.map(r => r && { active: r.finalityActive, reason: r.finalityReason, locked: r.latestLockedIndex, voters: r.checkpoints?.at(-1)?.voters ?? null }),
|
||||
};
|
||||
writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2));
|
||||
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'} (expect ${EXPECT}): epochs ${[...epochs.entries()].map(([e, v]) => `e${e}:v${v.class}:r${v.step}`).join(' ')}; first lock ${firstLock ? `index ${firstLock.index} at daa ${firstLock.daa}` : 'none'}; silent rows ${silentRows.length} (${silentRows.slice(0, 3).map(r => `utxo ${r.utxo_producer_sompi} exec ${r.exec_wei}`).join('; ')}); bridge rows ${bridgeRows.length}, voters identity ${bridgeIdentityVoters}, silent identity ${bridgeIdentitySilent}; leave ${leaveOutcome} voters ${votersBeforeLeave} -> ${votersAfterLeave}; blocks ${blocks.length} chain ${chainBlocks.length}; rejected ${rejectedNode.join('/')}; sinks ${sinks.join(' ')} at ${counts.join('/')}`);
|
||||
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'} (expect ${EXPECT}): epochs ${[...epochs.entries()].map(([e, v]) => `e${e}:v${v.class}:r${v.step}`).join(' ')}; first lock ${firstLock ? `index ${firstLock.index} at daa ${firstLock.daa}` : 'none'}; silent rows ${silentRows.length} (${silentRows.slice(0, 2).map(r => `daa ${r.paid_block_daa} utxo ${r.utxo_producer_sompi} silent ${r.expect_silent} voting ${r.expect_voting}`).join('; ')}), voting rows ${votingRows.length}; bridge ${bridgeRows.map(r => `${r.miner} utxo ${r.utxo_sompi} exec-tip ${r.exec_wei_less_tip} ${r.identity}`).join('; ')}; leave ${leaveOutcome} voters ${votersBeforeLeave} -> ${votersAfterLeave}; blocks ${blocks.length} chain ${chainBlocks.length}; rejected ${rejectedNode.join('/')}; sinks ${sinks.join(' ')} at ${counts.join('/')}`);
|
||||
for (const [k, v] of Object.entries(checks)) if (!v) log(`${EXPECT === 'bonus-fails' && k === 'bridge_identity_for_the_silent_key' ? 'EXPECTED FAILED CHECK' : 'FAILED CHECK'} ${k}`);
|
||||
log(`summary: ${TMP}/summary.json`);
|
||||
await stopAll();
|
||||
process.exit(pass ? 0 : 1);
|
||||
} catch (e) {
|
||||
log(`FAILED: ${e?.stack || e}`);
|
||||
await stopAll();
|
||||
process.exit(3);
|
||||
}
|
||||
|
|
|
|||
206
infra/fast-time/tn-late-join.mjs
Normal file
206
infra/fast-time/tn-late-join.mjs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
#!/usr/bin/env node
|
||||
// The testnet lane's copy of infra/fast-time/headers-proof-join.mjs (ca3-v4-node e5f993d4) for the late-join gate of
|
||||
// ledger N9's second half, 7 October 2026: the same join, with consensus proof verification and proving v0 from genesis
|
||||
// in the override and a prover beside node A (--prover), so the joiner's proofs come from A's archive.
|
||||
//
|
||||
// The 0.3.17 canary's own path (7 October 2026): a FRESH node joining through IBD WITH A HEADERS PROOF a chain whose sink
|
||||
// carries legal version-1026 signalling headers. The two-daemon test covers relay and headerless IBD; the proof path
|
||||
// needs a chain past the pruning depth, so this harness runs one on a fast-time profile at the Prunality floor
|
||||
// (finality 120, merge 60, k 18: pruning 2F + 4Mk + 2k + 2 = 4,598 DAA, set 4,600) and lets a fresh node join it.
|
||||
//
|
||||
// node A: override-60x.json re-depthed as above, the window object (window 120, floor 100000), IGNEUM_CLASS_SIGNAL=4,
|
||||
// three CPU threads (about 2.3 blocks/s on this Mac) until its pruning point has left genesis and the sink is
|
||||
// --margin blocks past that (about 35 minutes), then the miner stops.
|
||||
// node B: fresh, the same override, --addpeer A. PASS (--expect join): B's log carries "Starting IBD with headers
|
||||
// proof" and "IBD with peer ... completed successfully", B's sink equals A's within --watch seconds, and
|
||||
// the sink's header on B reads version 1026. The known-failed shape of the gate is the 0.3.17 binary (no
|
||||
// knob reproduces its raw comparison); the harness's own failed shape is `--expect stall`, which must FAIL.
|
||||
//
|
||||
// node infra/fast-time/headers-proof-join.mjs [--expect join|stall] [--margin 200] [--watch 600] [--threads 3]
|
||||
// IGNEUMD, IGNEUM_MINER name the binaries (defaults: vendor/igneum-node-0316/target-0316/release).
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, copyFileSync } from 'node:fs';
|
||||
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
|
||||
|
||||
const ROOT = new URL('../../', import.meta.url).pathname;
|
||||
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
|
||||
const BIN = process.env.IGNEUM_0316_BIN || `${ROOT}vendor/igneum-node-0316/target-0316/release`;
|
||||
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
|
||||
const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
|
||||
const TMP = process.env.IGNEUM_TN_TMP || '/tmp/igneum-fast-time-tn-join';
|
||||
const BASE = +(process.env.IGNEUM_TN_BASE_PORT || 30590), SUFFIX = +(process.env.IGNEUM_TN_SUFFIX || 997);
|
||||
const NEVER = '18446744073709551615';
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? Number(args[i + 1]) : dflt; };
|
||||
const sflag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
|
||||
const EXPECT = sflag('expect') || 'join';
|
||||
const WINDOW = Number(sflag('window') || 150);
|
||||
// The testnet lane's late-join case (ledger N9's second half, 7 October 2026): --prover "<command>" starts a prover
|
||||
// beside A's miner (its records land in A's blocks through the ordinary record flows; the command sees IGNEUM_GRPC
|
||||
// and IGNEUM_EVM_RPC for node A), and --join-after <blocks> holds B's start until A has mined that many blocks past
|
||||
// its ready point, so B joins after the pool's 600-chain-block window has passed and every proof it asks for must
|
||||
// come from the peers' archives. PASS is the same join; the failed shape is the node before the archive, which
|
||||
// stalls on "proofs this peer did not deliver in 20 s".
|
||||
const PROVER = sflag('prover') || null;
|
||||
const JOIN_AFTER = Number(sflag('join-after') || 0);
|
||||
const MARGIN = flag('margin', 200), WATCH = flag('watch', 600), THREADS = flag('threads', 3), MAX_MINE = flag('max-mine', 5400);
|
||||
const OUT = sflag('out') || `${TMP}/late-join-expect-${EXPECT}.json`;
|
||||
if (!['join', 'stall'].includes(EXPECT)) { console.error('usage: --expect join|stall'); process.exit(2); }
|
||||
const started = [];
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
||||
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
||||
|
||||
const baseText = readFileSync(FILE, 'utf8');
|
||||
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
|
||||
export function mergeOverrideText(text, fields) {
|
||||
let out = text;
|
||||
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
|
||||
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', ');
|
||||
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
|
||||
}
|
||||
// the depths at the Prunality floor: the blockrate block's finality_depth and pruning_depth replaced in place
|
||||
export function redepth(text) {
|
||||
// --window: the sampled difficulty window in samples (rate 4). The default 150 (the minimum) spans 600 DAA, which the
|
||||
// pruning point at DAA about 800 fills; 661 (the devnet's own) spans 2,644 DAA and is the known-failed shape found
|
||||
// on the first run (7 October 2026, 05:45 UK): the joiner's walk through the sampled trusted blocks runs out at a
|
||||
// gap before genesis and the rule "DAA window data has only N entries" ends the IBD. The devnet meets that for
|
||||
// the 2,644 DAA after its pruning point first leaves genesis; the node fix makes a young chain's short window legal.
|
||||
return text
|
||||
.replace(/"finality_depth":\s*\d+/, '"finality_depth": 120')
|
||||
.replace(/"pruning_depth":\s*\d+/, '"pruning_depth": 4600')
|
||||
.replace(/"difficulty_window_size":\s*\d+/, `"difficulty_window_size": ${WINDOW}`);
|
||||
}
|
||||
const DAY_MS = field('pow_day_ms');
|
||||
const override = `${TMP}/override.json`;
|
||||
writeFileSync(override, mergeOverrideText(redepth(baseText), {
|
||||
genesis_bits: 0x1f010000, skip_proof_of_work: false,
|
||||
program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: '100000', program_class_v4_signal_window_daa: 120,
|
||||
// the testnet lane's late-join case (ledger N9's second half): consensus proof verification from genesis under the
|
||||
// testnet object's pinned ids and proving v0 from genesis, so the prover's records are carried and verified, and a
|
||||
// joiner past the pool's window must get their proofs from the peers' archives
|
||||
proving_v0_activation_daa: '0', proving_consensus_verify_daa: '0',
|
||||
proving_shard_program_id: '0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a', proving_aggregator_id: '0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896',
|
||||
}));
|
||||
log(`expect ${EXPECT}: pruning depth 4600 (finality 120, merge 60, k 18), the window object set, signal byte 4, ${THREADS} threads, margin ${MARGIN}, watch ${WATCH} s`);
|
||||
|
||||
class Node {
|
||||
constructor(i, connect = null) {
|
||||
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
|
||||
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
|
||||
}
|
||||
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
|
||||
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
||||
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
||||
if (this.connect) a.push(`--addpeer=127.0.0.1:${this.connect}`); else a.push('--outpeers=0');
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_CLASS_SIGNAL: '4' } });
|
||||
started.push(this.proc);
|
||||
await sleep(1500);
|
||||
if (this.proc.exitCode != null) throw new Error(`n${this.i} exited ${this.proc.exitCode}: ${this.grepLog(/ERROR|Error|error|refused|invalid/).slice(-3).join(' | ')}`);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`); await this.rpc.call('getBlockDagInfo'); break; } catch { this.rpc = null; await sleep(500); }
|
||||
}
|
||||
if (!this.rpc) throw new Error(`n${this.i}: the RPC did not answer within 10 s`);
|
||||
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}${this.connect ? ` addpeer ${this.connect}` : ''}`);
|
||||
return this;
|
||||
}
|
||||
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
||||
async dag() { return this.rpc.call('getBlockDagInfo'); }
|
||||
async headerOf(hash) { const b = await this.rpc.call('getBlock', { hash, includeTransactions: false }); return b.block.header; }
|
||||
}
|
||||
function miner(name, grpc, threads, secs) {
|
||||
const out = openSync(`${TMP}/${name}.log`, 'a');
|
||||
const p = spawn(CPU_MINER, ['mine', grpc, String(threads), String(secs), name, '--engine', 'igneum-pow', '--payout-label', name, '--status-secs', '60', '--no-vote', '--stall-secs', '0'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
|
||||
started.push(p);
|
||||
return p;
|
||||
}
|
||||
async function stopAll() {
|
||||
for (const p of [...started].reverse()) { try { p.kill('SIGINT'); } catch { } }
|
||||
await sleep(1500);
|
||||
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
|
||||
}
|
||||
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
|
||||
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
||||
|
||||
const a = await new Node(0).start();
|
||||
const genesis = (await a.dag()).pruningPointHash;
|
||||
const m = miner('a-miner', a.grpc, THREADS, MAX_MINE);
|
||||
// the testnet lane's prover beside A's miner (its records reach A's blocks through the record flows)
|
||||
let prover = null;
|
||||
if (PROVER) {
|
||||
const out = openSync(`${TMP}/prover.log`, 'a');
|
||||
prover = spawn('/bin/sh', ['-c', PROVER], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_GRPC: a.grpc, IGNEUM_EVM_RPC: `http://127.0.0.1:${a.evmPort}` } });
|
||||
log(`prover up pid ${prover.pid}: ${PROVER}`);
|
||||
}
|
||||
const t0 = Date.now();
|
||||
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
||||
let lastReport = 0, pruningMovedAt = null, pruningPoint = null;
|
||||
while (Date.now() - t0 < MAX_MINE * 1000) {
|
||||
await sleep(10000);
|
||||
const d = await a.dag();
|
||||
if (pruningMovedAt == null && d.pruningPointHash !== genesis) { pruningMovedAt = { t: +since(), daa: +d.virtualDaaScore, blocks: d.blockCount }; pruningPoint = d.pruningPointHash; log(`A's pruning point left genesis at ${since()} s: ${String(d.pruningPointHash).slice(0, 8)} at DAA ${d.virtualDaaScore}, ${d.blockCount} blocks`); }
|
||||
if (Date.now() - lastReport > 120000) { lastReport = Date.now(); log(`t=${since()} s A ${d.blockCount} blocks daa ${d.virtualDaaScore} pruning ${String(d.pruningPointHash).slice(0, 8)}${pruningMovedAt ? ' (moved)' : ''}`); }
|
||||
if (pruningMovedAt && d.blockCount >= pruningMovedAt.blocks + MARGIN) break;
|
||||
}
|
||||
// --join-after: A's miner runs on this many blocks past the ready point (past the pool's 600-chain-block record
|
||||
// window, so every proof B asks for must come from the peers' archives), then stops as before so B joins a still sink
|
||||
if (JOIN_AFTER > 0) {
|
||||
const ready = (await a.dag()).blockCount;
|
||||
const target = ready + JOIN_AFTER;
|
||||
log(`A ready at ${ready} blocks; mining on to ${target} (${JOIN_AFTER} past the ready point) before B joins`);
|
||||
for (;;) {
|
||||
const d = await a.rpc.call('getBlockDagInfo');
|
||||
if (d.blockCount >= target) break;
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
try { m.kill('SIGINT'); } catch { }
|
||||
try { prover?.kill('SIGINT'); } catch { }
|
||||
await sleep(3000);
|
||||
const aAtJoin = await a.dag();
|
||||
const aSinkHeader = await a.headerOf(aAtJoin.sink);
|
||||
log(`A ready for the join: ${aAtJoin.blockCount} blocks, ${aAtJoin.headerCount} headers, daa ${aAtJoin.virtualDaaScore}, pruning point ${String(aAtJoin.pruningPointHash).slice(0, 8)}, sink ${String(aAtJoin.sink).slice(0, 8)} version ${aSinkHeader.version}`);
|
||||
if (!pruningMovedAt) { log(`SUMMARY FAIL (expect ${EXPECT}): A's pruning point never left genesis in ${MAX_MINE} s; the chain is too short for a headers-proof join`); await stopAll(); process.exit(1); }
|
||||
|
||||
// the fresh node
|
||||
const b = await new Node(1, a.p2pPort).start();
|
||||
const tj = Date.now();
|
||||
let joined = null, proofLine = null, doneLine = null, lastJ = 0;
|
||||
while (Date.now() - tj < WATCH * 1000) {
|
||||
await sleep(5000);
|
||||
const d = await b.dag().catch(() => null);
|
||||
proofLine = proofLine || b.grepLog(/Starting IBD with headers proof/)[0] || null;
|
||||
doneLine = doneLine || b.grepLog(/IBD with peer .* completed successfully/)[0] || null;
|
||||
if (d && String(d.sink) === String(aAtJoin.sink) && joined == null) { joined = { t: (Date.now() - tj) / 1000, blocks: d.blockCount, headers: d.headerCount }; log(`B reached A's sink at ${joined.t} s after the join: ${d.blockCount} blocks, ${d.headerCount} headers`); }
|
||||
if (Date.now() - lastJ > 30000) { lastJ = Date.now(); log(`t+${((Date.now() - tj) / 1000).toFixed(0)} s B ${d?.blockCount} blocks ${d?.headerCount} headers sink ${d ? String(d.sink).slice(0, 8) : '?'}; proof line ${proofLine ? 'yes' : 'no'}, done ${doneLine ? 'yes' : 'no'}`); }
|
||||
if (joined && doneLine) break;
|
||||
}
|
||||
const bSinkVersion = joined ? (await b.headerOf(aAtJoin.sink).catch(() => null))?.version ?? null : null;
|
||||
const errors = b.grepLog(/flow error|header version mismatch|wrong block version|completed with error/).slice(0, 5).map(l => l.replace(/^.*?\] /, '').slice(0, 200));
|
||||
const checks = {
|
||||
pruning_point_moved: !!pruningMovedAt,
|
||||
a_sink_signalling: aSinkHeader.version === 1026,
|
||||
headers_proof_path_taken: !!proofLine,
|
||||
ibd_completed: !!doneLine,
|
||||
b_reached_a_sink: !!joined,
|
||||
b_sink_version_1026: bSinkVersion === 1026,
|
||||
no_version_refusal: !errors.some(e => /header version mismatch|wrong block version/.test(e)),
|
||||
};
|
||||
const good = EXPECT === 'join'
|
||||
? Object.values(checks).every(Boolean)
|
||||
: !(checks.b_reached_a_sink && checks.ibd_completed);
|
||||
const summary = { pass: good, expect: EXPECT, window: WINDOW, pruning_depth: 4600, margin: MARGIN, threads: THREADS, a_at_join: { blocks: aAtJoin.blockCount, headers: aAtJoin.headerCount, daa: aAtJoin.virtualDaaScore, pruning: String(aAtJoin.pruningPointHash).slice(0, 16), sink: String(aAtJoin.sink).slice(0, 16), sink_version: aSinkHeader.version }, pruning_moved_at: pruningMovedAt, joined, proof_line: proofLine?.replace(/^.*?\] /, '').slice(0, 200) ?? null, done_line: doneLine?.replace(/^.*?\] /, '').slice(0, 200) ?? null, b_sink_version: bSinkVersion, b_errors: errors, checks, node: IGNEUMD, miner: CPU_MINER };
|
||||
mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(summary, null, 2));
|
||||
try { copyFileSync(b.logFile, OUT.replace(/\.json$/, '-b-node.log')); } catch { }
|
||||
const fails = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
|
||||
log(`SUMMARY ${good ? 'PASS' : 'FAIL'} (expect ${EXPECT}): A ${aAtJoin.blockCount} blocks at DAA ${aAtJoin.virtualDaaScore} with the pruning point moved at ${pruningMovedAt?.t} s, sink version ${aSinkHeader.version}; B ${proofLine ? 'took the headers-proof path' : 'did not take the headers-proof path'}, ${doneLine ? 'IBD completed' : 'IBD not completed'}, ${joined ? `reached A's sink at ${joined.t} s with ${joined.headers} headers` : 'did not reach the sink of A'}, sink version on B ${bSinkVersion}; B errors: ${errors.length}${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`);
|
||||
log(`summary: ${OUT}`);
|
||||
await stopAll();
|
||||
process.exit(good ? 0 : 1);
|
||||
83
infra/fast-time/tn-prover-loop.mjs
Normal file
83
infra/fast-time/tn-prover-loop.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env node
|
||||
// A prover beside a fast-time node (the testnet genesis lane, 7 October 2026): every --every seconds it asks the node for
|
||||
// the shards its key is assigned, takes the newest empty one it has not proved, exports the chain, cuts the fixture,
|
||||
// proves the shard on the CPU (igneum-prove-host --mode compressed, SP1_PROVER=cpu), signs the record with the key and
|
||||
// submits it with the proof bytes. Its records reach the node's blocks through the ordinary record flows, which is what
|
||||
// the late-join gate needs: proofs carried by blocks that leave the pool's window before a joiner asks for them.
|
||||
//
|
||||
// IGNEUM_GRPC=grpc://127.0.0.1:P IGNEUM_EVM_RPC=http://127.0.0.1:Q node infra/fast-time/tn-prover-loop.mjs \
|
||||
// --label a-miner --chain igneum-devnet-997 [--every 20] [--window 100] [--tmp /tmp/tn-prover]
|
||||
// IGNEUM_MINER, IGNEUM_PROVE_HOST, IGNEUM_PROVE_EXPORT name the binaries (the miner for key-hash and sign-record).
|
||||
//
|
||||
// One RESULT line per event (assigned, export, prove, submit, refused) with a UTC stamp; the loop never kills anything.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const sflag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
|
||||
const LABEL = sflag('label', 'a-miner');
|
||||
const CHAIN = sflag('chain', 'igneum-devnet-997');
|
||||
const EVERY = +sflag('every', 20);
|
||||
const WINDOW = +sflag('window', 100);
|
||||
const TMP = sflag('tmp', '/tmp/tn-prover');
|
||||
const EVM = process.env.IGNEUM_EVM_RPC || 'http://127.0.0.1:30593';
|
||||
const MINER = process.env.IGNEUM_MINER;
|
||||
const HOST = process.env.IGNEUM_PROVE_HOST;
|
||||
const EXPORT = process.env.IGNEUM_PROVE_EXPORT;
|
||||
const PAYOUT = '0x4242424242424242424242424242424242424242';
|
||||
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||
for (const b of [MINER, HOST, EXPORT]) if (!b || !existsSync(b)) { console.error(`missing binary: ${b}`); process.exit(2); }
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
|
||||
async function rpc(method, params) {
|
||||
const r = await fetch(EVM, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`);
|
||||
return j.result;
|
||||
}
|
||||
function run(bin, argv, env = {}) {
|
||||
const t = Date.now();
|
||||
const r = spawnSync(bin, argv, { encoding: 'utf8', env: { ...process.env, ...env }, maxBuffer: 1 << 28 });
|
||||
return { code: r.status, out: `${r.stdout || ''}${r.stderr || ''}`, secs: ((Date.now() - t) / 1000).toFixed(1) };
|
||||
}
|
||||
const kh = run(MINER, ['key-hash', LABEL]);
|
||||
const KEY_HASH = (kh.out.trim().split('\n').pop() || '').trim();
|
||||
if (!/^[0-9a-f]{64}$/.test(KEY_HASH)) { console.error(`key-hash ${LABEL}: ${kh.out}`); process.exit(2); }
|
||||
log(`RESULT start label=${LABEL} key=${KEY_HASH} chain=${CHAIN} evm=${EVM} every=${EVERY}s window=${WINDOW}`);
|
||||
const done = new Set();
|
||||
let proved = 0, refused = 0;
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
for (;;) {
|
||||
try {
|
||||
const work = await rpc('igneum_getAssignedShards', [[`0x${KEY_HASH}`], WINDOW]);
|
||||
const mine = (work || []).filter(w => w.assigned && w.txCount === 0 && !w.paid && !done.has(`${w.number}/${w.shard}`));
|
||||
mine.sort((x, y) => parseInt(y.number, 16) - parseInt(x.number, 16));
|
||||
const target = mine[0];
|
||||
if (!target) { await sleep(EVERY * 1000); continue; }
|
||||
const number = parseInt(target.number, 16);
|
||||
done.add(`${target.number}/${target.shard}`);
|
||||
log(`RESULT assigned number=${number} shard=${target.shard} listed=${work.length} mine=${mine.length}`);
|
||||
const plan = await rpc('igneum_getShardPlan', [target.number]);
|
||||
const seq = await rpc('igneum_exportSegments', ['0x0', target.number]);
|
||||
writeFileSync(`${TMP}/seq.json`, JSON.stringify(seq));
|
||||
const fixture = `${TMP}/block-${number}.json`;
|
||||
const ex = run(EXPORT, [`${TMP}/seq.json`, String(number), fixture, '--source', `tn late-join network block ${number}`]);
|
||||
if (ex.code !== 0) { log(`RESULT export_failed number=${number} secs=${ex.secs} tail=${ex.out.split('\n').slice(-3).join(' | ').slice(0, 300)}`); continue; }
|
||||
log(`RESULT export number=${number} secs=${ex.secs}`);
|
||||
const results = `${TMP}/results-${number}-${target.shard}.json`;
|
||||
const pr = run(HOST, [fixture, '--mode', 'compressed', '--shard', String(target.shard), '--prover', PAYOUT, '--out', results], { SP1_PROVER: 'cpu', RUST_LOG: 'off' });
|
||||
if (pr.code !== 0) { log(`RESULT prove_failed number=${number} secs=${pr.secs} tail=${pr.out.split('\n').filter(l => /RESULT|rror/.test(l)).slice(-3).join(' | ').slice(0, 400)}`); continue; }
|
||||
const res = JSON.parse(readFileSync(results, 'utf8'));
|
||||
log(`RESULT prove number=${number} shard=${target.shard} secs=${pr.secs} cycles=${res.cycles} proof_bytes=${res.compressed_proof_bytes}`);
|
||||
const sg = run(MINER, ['sign-record', LABEL, CHAIN, plan.hash, String(number), String(target.shard), PAYOUT, res.statement, res.proof_sha256]);
|
||||
if (sg.code !== 0) { log(`RESULT sign_failed number=${number} out=${sg.out.slice(0, 300)}`); continue; }
|
||||
const signed = JSON.parse(sg.out.trim().split('\n').pop());
|
||||
const proofHex = '0x' + readFileSync(res.proof_file).toString('hex');
|
||||
const sub = await rpc('igneum_submitProofRecord', [{ record: signed.record, proof: proofHex }]);
|
||||
if (sub.accepted) { proved++; log(`RESULT submit number=${number} shard=${target.shard} accepted proved_total=${proved}`); } else { refused++; log(`RESULT refused number=${number} shard=${target.shard} reason=${sub.reason} refused_total=${refused}`); }
|
||||
} catch (e) {
|
||||
log(`RESULT error ${String(e.message || e).slice(0, 300)}`);
|
||||
await sleep(EVERY * 1000);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue