Counter ASIC 3.0 gates (node): the node-cut compat gate (node-compat.mjs: a mining new node beside a 0.3.14 node on the live object, both joins, the served genesis sync, the restart; PASS on 7961c5f1, FAIL on the canary binary 713ef876), the ledger rows N1 (version 1026 on the live file) and N2 (the pruned node's sync panic, a remote crash vector), the P4 row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-josh 2026-10-06 21:25:39 +01:00
parent 89b4a0fff4
commit 4d7e677168
5 changed files with 368 additions and 0 deletions

View file

@ -2020,6 +2020,30 @@ Evidence: the commits above. Experiment: `curl https://igneum.network/api/live`
- **E5, L9** (the client dev fee), evening: the fee is built, switchable and audited (E18). The homepage's miner section now carries the litepaper's sentence (`site/index.html`, under the download buttons: "The protocol carries no fee. The Igneum Miner software takes an optional 1% dev fee, like other GPU miners, off with one flag"), and the litepaper's "What a miner's hour looks like" states the mechanism, the flag and the audit. The "no value" and "may be reset" devnet wording of L9 is still open.
- **D2** (the app share). The "Why build here" text is applied at `site/litepaper.html:355` and states the number; two gaps noted under E17.
## Node findings (6 October 2026, evening): the 0.3.15 canary and the sync crash, fixed before the cut
Two faults found on the live devnet by the fleet's canary boxes and the hub while 0.3.15 (class v4) was held for its cut. Both are conceded as ours, both are fixed on the fork branch `ca3-v4-order-fix` for release-0.3.15-node (the shipper's tree), both carry a test and a gate that every future node cut runs (`infra/fast-time/node-compat.mjs`).
### N1. A 0.3.15 node on the live file wrote blocks every 0.3.14 node rejected
"p2-3090-1 on 713ef876 with the live thirteen-field file, at every reconnect since its 19:42Z restart: P2P, got reject message: wrong block version: got 1026 but expected 2 from peer 188.245.5.161:26611; blocks 122,630, synced false, peers 0."
Status: Fixed (6 October 2026, 20:0xZ, fork commit 17c60367 on ca3-v4-order-fix; in 0.3.15). Conceded: the node lane's fault.
Answer: the class v4 signal (PROPOSED, `docs/plans/counter-asic-3-node.md` section 6) is the producer's object version in the high byte of the header version; the first 0.3.15 build stamped it from the binary alone, so on the live file (no window, no floor) a new node wrote version 1026 and every 0.3.14 node, whose rule is version 2 or reject, refused its blocks and its relays, although the digest compat rule (the same evening) made the handshake peer. A new miner lost every block; a lagging new node could not re-sync. The fix gates the stamp and the signal read on publish 2's object (both `program_class_v4_signal_window_daa` and `program_class_v4_activation_daa` set): on the thirteen-field file the header is byte for byte what 0.3.14 writes. Why the gates missed it: the digest compat harness peered the two binaries but neither mined; the new gate mines on both sides, joins a clean node through each, restarts the new node and re-syncs it.
Evidence: unit tests `block_template_uses_current_block_version` (version 2 with no window and no floor, 1026 with both) and `program_class_signal_rule`; the gate `infra/fast-time/node-compat.mjs` on the fixed binary beside 0.3.14 on the live object: one digest on all four nodes, the new miner's 73 blocks accepted by the old hub, counts 169/169/169/169, header versions {0: the genesis, 2: 169}, no reject line, the clean join and the restart re-sync (PASS, 20:06Z; `docs/plans/counter-asic-3-gate/node-compat-20261006-fixed.json`); the same gate on the canary binary 713ef876 reproduces the fault (`HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting`, counts 138 against 101, the restarted node never re-synced: FAIL, `node-compat-20261006-canary-failed-case.json`).
### N2. Any peer could crash any pruned node with a sync request below its retention
"thread 'tokio-rt-worker' panicked at consensus/src/processes/sync/mod.rs:87:62: called Result::unwrap() on an Err value: KeyNotFound(GhostdagCompact/0/edc4fa84...) then Exiting..." (the hub, a pruned 0.3.14 node, 19:57Z, when p2-3090-1, cut off since 19:42Z, began a sync from the genesis against it).
Status: Fixed (6 October 2026, 20:2xZ, fork commit 7961c5f1 on ca3-v4-order-fix; in 0.3.15). Conceded: a remote crash vector in every release from the first pruned devnet node to 0.3.14; security.
Answer: `SyncManager::antipast_hashes_between` (the IBD headers path, `RequestHeaders`) unwrapped the GHOSTDAG reads of the requested low block and of every chain block of the walk; a pruned node holds no GHOSTDAG data below its retention, so a request from the genesis killed the serving node, not the requester. The fix makes the walk fallible: a read below the retention is `SyncManagerError::BlockBelowRetention(hash)` (also in `find_highest_common_chain_block` and the pruning-point locator), the consensus API returns it, and the serving flow answers the peer with the error and disconnects it, the node alive; the peer syncs from a node that holds the history. The hub was restarted by hand at 20:00Z (synced in 25 s).
Evidence: unit test `a_sync_request_below_retention_is_an_error_not_a_panic` (a chain of six headers, the genesis's GHOSTDAG entry deleted as a pruned node holds it, `get_hashes_between(genesis, tip)` returns the error, a request inside the retention still answers); the gate's served-join case (a clean old node syncing from the genesis through the NEW node, which must stay alive and serve it) in `node-compat.mjs`; the box's kaspa-consensus and consensus-core suites on the commit. A truly pruned serving node in a harness needs hours of fast-time chain (pruning depth 13,838 DAA) and is owed; the unit test stands in for it.
## Status updates, 5 October 2026 (ledger sweep, night of 4 to 5 October)
`docs/review/ledger-sweep-2026-10-05.md` holds the runs, the commands and the running table. Every Open, Proposed, Unmeasured or pending entry was read against its experiment line; the status lines above carry the evidence inline, marked "Sweep (5 October 2026)" where a note was added and "Was:" where the status changed. Items owned by the other two night branches (F23, F24, G12, X18, the flood memory growth; the base-fee floor, testnet parameters, G13, G14, public text) were left to them.

View file

@ -0,0 +1,94 @@
{
"pass": false,
"checks": {
"one_digest_on_old_and_new": true,
"new_miner_blocks_accepted": true,
"old_miner_blocks_accepted": true,
"zero_rejected_by_miners": true,
"no_wrong_version_or_reject_line": false,
"every_header_version_is_the_block_version": false,
"old_nodes_hold_the_new_blocks": false,
"counts_agree_at_the_end": false,
"clean_new_node_joined_and_synced": true,
"restarted_new_node_resynced": false
},
"new_binary": "/private/tmp/claude-501/-Users-joshm/cd75457f-4858-4f86-9634-7481ee056b7b/scratchpad/igneumd-713ef876",
"old_binary": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-0314/release/igneumd",
"digests": [
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2"
],
"counts_after_mining": [
{
"count": 97,
"sink": "7c26e545e4982ac9",
"daa": 97
},
{
"count": 101,
"sink": "11439a8867718a2b",
"daa": 101
},
{
"count": 97,
"sink": "7c26e545e4982ac9",
"daa": 97
},
{
"count": 97,
"sink": "7c26e545e4982ac9",
"daa": 97
}
],
"final": [
{
"count": 138,
"sink": "360b8259384d8fc1",
"daa": 138
},
{
"count": 101,
"sink": "11439a8867718a2b",
"daa": 101
},
{
"count": 138,
"sink": "360b8259384d8fc1",
"daa": 138
},
{
"count": 138,
"sink": "360b8259384d8fc1",
"daa": 138
}
],
"accepted_old_new": [
138,
100
],
"rejected_by_miners_old_new": [
0,
0
],
"reject_lines": [
[
"HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting from peer 127.0.0.1:51290.",
"HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting from peer 127.0.0.1:51818.",
"HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting from peer 127.0.0.1:52104."
],
[
"P2P, got reject message: wrong block version: got 1026 but expected 2 from peer: 127.0.0.1:29831",
"P2P, got reject message: wrong block version: got 1026 but expected 2 from peer: 127.0.0.1:29831",
"P2P, got reject message: wrong block version: got 1026 but expected 2 from peer: 127.0.0.1:29831"
],
[],
[]
],
"header_versions": {
"0": 1,
"2": 138
},
"secs": 120
}

View file

@ -0,0 +1,102 @@
{
"pass": true,
"checks": {
"one_digest_on_old_and_new": true,
"new_miner_blocks_accepted": true,
"old_miner_blocks_accepted": true,
"zero_rejected_by_miners": true,
"no_wrong_version_or_reject_line": true,
"every_header_version_is_the_block_version": true,
"old_nodes_hold_the_new_blocks": true,
"counts_agree_at_the_end": true,
"clean_new_node_joined_and_synced": true,
"restarted_new_node_resynced": true,
"new_node_served_a_genesis_sync_and_survived": true,
"no_panic_in_any_node_log": true
},
"new_binary": "vendor/igneum-node-ca3v4/target-ca3v4/release/igneumd",
"old_binary": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-0314/release/igneumd",
"digests": [
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2"
],
"counts_after_mining": [
{
"count": 165,
"sink": "44a4876cfc2ba782",
"daa": 165
},
{
"count": 165,
"sink": "44a4876cfc2ba782",
"daa": 165
},
{
"count": 165,
"sink": "44a4876cfc2ba782",
"daa": 165
},
{
"count": 165,
"sink": "44a4876cfc2ba782",
"daa": 165
},
{
"count": 165,
"sink": "44a4876cfc2ba782",
"daa": 165
}
],
"final": [
{
"count": 195,
"sink": "0be1fa5e67bedeb1",
"daa": 195
},
{
"count": 195,
"sink": "0be1fa5e67bedeb1",
"daa": 195
},
{
"count": 195,
"sink": "0be1fa5e67bedeb1",
"daa": 195
},
{
"count": 195,
"sink": "0be1fa5e67bedeb1",
"daa": 195
},
{
"count": 195,
"sink": "0be1fa5e67bedeb1",
"daa": 195
}
],
"served_join_count": 165,
"serving_node_alive": true,
"accepted_old_new": [
120,
75
],
"rejected_by_miners_old_new": [
0,
0
],
"reject_lines": [
[],
[],
[],
[],
[]
],
"header_versions": {
"0": 1,
"2": 195
},
"secs": 160
}

View file

@ -19,4 +19,6 @@
| P3 | the 0.3.15 digest rule (main's ruling, 20:1x UK): an absent class v4 field contributes nothing to the digest, so publish 1 (the binary) splits no handshake and publish 2 (the sixteen-field file) is the one sweep | the compat case and the refusal case on real nodes; the pinned fixtures | GREEN on fork ca3-v4-order-fix 713ef876 (with the order-test race fix 791ff22c; both handed to the shipper for release-0.3.15-node): `infra/fast-time/digest-compat.mjs` (18:58Z, suffix 995, the live thirteen-field file copied, never written): the fixed node and a 0.3.14 node (`target-0314/release/igneumd`) on the thirteen-field file print ONE digest `a89be8a7e64b7d5a...` and peer (n0 has 2 peers, the old node 1: the compat case); the fixed node on the sixteen-field file (the exec pin, the floor 219,600, the window 86,400) prints `db9a85f9ff08f72f...` and has no peer, the handshake's `Refusing peer ...: consensus params digest mismatch` line in the log (the refusal case, the rule's known-failed case: present fields move the digest). On the devnet network id the sixteen-field object digests to `1dddfa5574d5536109a5ae68dc415b55e954bd11208608440845e19670505871` (the fixed Mac binary's start line = the pinned test value), the thirteen-field live file to `b18ed271f75dd464...` (0.3.14's value), fourteen fields `23e76936...`; no file `c562d70e...` (0.3.11 to 0.3.15 unchanged). Box line on 713ef876: kaspa-consensus 98, consensus-core 111 + 7, rc 0. The rehearsal object sets both fields, so its `a15db4f0` on devnet-400 stands. The shipper's independent readings on the 0.3.15 Mac binary (release-0.3.15-node = 4c6b129d + 0562a7f2 + 2e5d3d30 + 791ff22c + 713ef876, 19:0xZ): the same three values, and at tonight's live floor 226,800 the sixteen-field object reads `2c1162e2...`, the publish-2 digest if the floor stays there (recomputed at the publish); the 0.3.14 binary exits at parse on the sixteen-field file, which is why the file follows the binary; its suite on igneum-build-1 at 713ef876: kaspa-consensus 97 + the recorded flake alone 1 of 1, consensus-core 111 + 7, igneum-exec 20, kaspa-pow 15 with both engine tests, igneum-miner 18, p2p-flows 33. Summary `digest-compat-20261006.json` |
| P4 | the two 0.3.15 node faults found on the live devnet (the canary's version 1026, the pruned hub's sync panic), fixed in the same fork tree | the unit tests; the node-cut compat gate on real nodes (a MINING new node beside a 0.3.14 node on the live object, the old node accepting the new node's blocks; a clean new node joining through the old hub; a clean OLD node joining through the NEW node, which must survive serving a sync from the genesis; the new node restarted and re-synced; every header version the block version; no panic); the ledger rows | GREEN on fork ca3-v4-order-fix 7961c5f1 (= 791ff22c + 713ef876 + 17c60367 the signal gate + 7961c5f1 the retention error; the shipper's release-0.3.15-node). The gate `infra/fast-time/node-compat.mjs` (20:15:5x to 20:19:04Z, suffix 994, the live object plus CPU genesis bits, the 0.3.14 binary `target-0314/release/igneumd` as the old side): SUMMARY PASS: one digest `b0afb2ee93d0d627` on all five nodes; accepted old/new 120/75, rejected 0/0; header versions {0: the genesis, 2: 195}; counts 195/195/195/195/195 at the end (the clean new node through the old hub, the clean old node served from the genesis by the new node, which stayed alive, and the restarted new node all at 195); no `wrong block version`, reject or panic line in any log. The known-failed case on the canary binary 713ef876 beside 0.3.14 (20:00 to 20:02Z): SUMMARY FAIL, `HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting` on the old nodes, `P2P, got reject message: wrong block version` on the new, counts 138 against the new node's 101, the restarted new node never re-synced (the fleet's p2-3090-1). Unit tests: `block_template_uses_current_block_version` (2 on the thirteen-field state, 1026 with both v4 fields), `program_class_signal_rule`, `a_sync_request_below_retention_is_an_error_not_a_panic`; the box on 7961c5f1: kaspa-consensus 99, consensus-core 111 + 7, rc 0 (the shipper's six-crate line: exec 20, miner 18, pow 15, p2p-flows 33). Ledger rows N1 and N2 in `docs/fud-ledger.md`. Owed: a truly pruned serving node in a harness (hours of fast-time chain at pruning depth 13,838); an old node whose datadir holds version-1026 blocks relaying them (the shipper's roll-back finding), queued. Summaries `node-compat-20261006-fixed.json`, `node-compat-20261006-canary-failed-case.json` |
Summary files in this directory: `class-v4-20261006-1553Z-cpu.json` (G4 run 1), `class-v4-20261006-never-failed-case.json` (G4 run 2, the failed case), `class-v4-20261006-metal.json` (G4b), `class-v4-20261006-id-rerun.json` (G4 on the program-id fix, with the id assertion), `class-v4-20261006-id-failed-case.json` (the id assertion's failed case).

View file

@ -0,0 +1,146 @@
#!/usr/bin/env node
// The node-cut compat gate (the 0.3.15 canary, 6 October 2026, 19:4xZ: a new node on the live file wrote header version
// 1026 and every 0.3.14 node rejected its blocks as a wrong block version although the handshake peered; the digest
// compat case alone did not catch it because nothing mined). Every future node cut runs this before its publish 1.
//
// Four nodes on a private network (suffix 994, ports 29830 and up, /tmp/igneum-node-compat), on the LIVE override
// object (copied from /tmp/igneum-devnet/override-v3.json, never written) plus `genesis_bits` 0x1f010000 so CPU miners
// find blocks (a field both binaries know; no class v4 field, so the digest is the same on both):
// n0 OLD node (the release in the field), mining the hub
// n1 NEW node, MINING, connected to n0 its blocks must be accepted by n0 (the canary case)
// n2 OLD node, connected to n0 sees the new node's blocks through the old one
// n3 NEW node, CLEAN, started late, connected to n0 the fresh join through the OLD node: must sync to the chain
// n4 OLD node, CLEAN, started late, connected to n1 the fresh join SERVED BY THE NEW node (the 19:57Z hub class:
// the serving node must survive a sync from the genesis)
// then n1 is RESTARTED and must re-sync from n0 (the lagging-node case p2-3090-1 failed).
// The pruned-serving-node case itself (a serving node whose history below its retention is gone) is the unit test
// `a_sync_request_below_retention_is_an_error_not_a_panic` (consensus/src/processes/sync/mod.rs): a pruned node takes
// hours of chain on the fast-time profile (pruning depth 13,838 DAA), so this harness proves the serving path alive
// and the test proves the error in place of the panic.
// PASS: one digest on all four; every node's block count equal at the end (within the relay lag); the new node's
// accepted blocks over 0; no "wrong block version" or "reject message" line in any log; n3 reaches the count after
// joining; n1 after its restart reaches the count; every mined header's version byte is the block version.
// node infra/fast-time/node-compat.mjs --new <igneumd> --old <igneumd> --miner <igneum-miner> [--secs 150]
// The known-failed case: --new <the 713ef876 binary> (the canary's): n0 rejects n1's blocks, FAIL.
import { spawn } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
const args = process.argv.slice(2);
const sflag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };
const NEW = sflag('new'), OLD = sflag('old'), MINER = sflag('miner');
const SECS = +(sflag('secs') || 150);
for (const b of [NEW, OLD, MINER]) if (!b || !existsSync(b)) { console.error('usage: --new <igneumd> --old <igneumd> --miner <igneum-miner>'); process.exit(2); }
const TMP = '/tmp/igneum-node-compat';
const BASE = 29830, SUFFIX = 994;
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const live = readFileSync('/tmp/igneum-devnet/override-v3.json', 'utf8').trim();
if (!/^\{.*\}$/s.test(live)) { console.error('the live file is not a JSON object'); process.exit(2); }
const file = `${TMP}/override.json`;
writeFileSync(file, live.replace(/\}\s*$/, ',"genesis_bits":520159232,"skip_proof_of_work":false}\n'));
const DAY_MS = 86_400_000;
const started = [];
class Node {
constructor(i, bin, connect) { this.i = i; this.bin = bin; this.connect = connect; this.grpcPort = BASE + i * 10; this.p2p = BASE + i * 10 + 1; this.json = BASE + i * 10 + 2; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; this.minerProc = null; }
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', '--utxoindex', '--enable-unsynced-mining', `--appdir=${this.dir}`,
`--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.json}`, `--listen=127.0.0.1:${this.p2p}`, `--override-params-file=${file}`, '--loglevel=info', '--yes'];
// --addpeer, not --connect: kaspad's --connect means "connect only to these peers" and takes no inbound, and the
// served-join case needs the new node to ACCEPT a connection (the first run of this gate left n4 at 0 blocks for
// that reason, a harness fault); the hub takes no outbound at all
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(this.bin, a, { stdio: ['ignore', out, out] }); started.push(this.proc);
await sleep(1500);
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.json}`); } catch (e) { log(`n${this.i} rpc: ${e.message}`); }
return this;
}
mine(label, secs) {
const out = openSync(`${TMP}/${label}.log`, 'a');
this.minerProc = spawn(MINER, ['mine', this.grpc, '1', String(secs), label, '--engine', 'igneum-pow', '--payout-label', label, '--status-secs', '30', '--no-vote'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
started.push(this.minerProc);
}
async stop() { try { this.proc.kill('SIGINT'); } catch { } await sleep(2500); try { this.proc.kill('SIGKILL'); } catch { } }
grep(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
digest() { return (this.grep(/Consensus params digest/)[0] || '').replace(/^.*digest: /, '').slice(0, 64) || null; }
async count() { try { const d = await this.rpc.call('getBlockDagInfo'); return { count: d.blockCount, sink: String(d.sink).slice(0, 16), daa: d.virtualDaaScore }; } catch (e) { return { count: null, sink: `rpc: ${e.message}` }; } }
}
const minerLog = (label) => { try { return readFileSync(`${TMP}/${label}.log`, 'utf8').split('\n'); } catch { return []; } };
async function stopAll() { for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } await sleep(2000); for (const p of started) { try { p.kill('SIGKILL'); } catch { } } }
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
const n0 = await new Node(0, OLD, null).start();
const n1 = await new Node(1, NEW, n0.p2p).start();
const n2 = await new Node(2, OLD, n0.p2p).start();
log(`n0 old digest ${n0.digest()?.slice(0, 16)}, n1 new ${n1.digest()?.slice(0, 16)}, n2 old ${n2.digest()?.slice(0, 16)}`);
n0.mine('old0', SECS + 60);
n1.mine('new1', SECS + 60);
const phase1 = Math.floor(SECS * 0.5);
await sleep(phase1 * 1000);
let c = await Promise.all([n0, n1, n2].map(n => n.count()));
log(`t=${since()} after mining on both: counts ${c.map(x => x.count).join('/')} sinks ${c.map(x => x.sink).join(' ')}`);
// the fresh join: a clean new node through the OLD hub
const n3 = await new Node(3, NEW, n0.p2p).start();
const n4 = await new Node(4, OLD, n1.p2p).start();
log(`n3 new (clean join through the old hub) digest ${n3.digest()?.slice(0, 16)}; n4 old (clean join served by the new node) digest ${n4.digest()?.slice(0, 16)}`);
await sleep(Math.floor(SECS * 0.25) * 1000);
c = await Promise.all([n0, n1, n2, n3, n4].map(n => n.count()));
log(`t=${since()} after the joins: counts ${c.map(x => x.count).join('/')}; the new node serving n4 is ${n1.proc.exitCode == null ? 'alive' : 'DEAD'}`);
const joinedCount = c[3].count, hubAtJoin = c[0].count, servedCount = c[4].count;
const servingNodeAlive = n1.proc.exitCode == null && n1.proc.signalCode == null;
// the restart: n1 (the new, mining node) stopped and started again, must re-sync from the old hub
try { n1.minerProc.kill('SIGINT'); } catch { }
await n1.stop();
const n1b = new Node(1, NEW, n0.p2p); await n1b.start();
log(`n1 restarted (pid ${n1b.proc.pid})`);
await sleep(Math.floor(SECS * 0.25) * 1000);
const fin = await Promise.all([n0, n1b, n2, n3, n4].map(n => n.count()));
log(`t=${since()} final: counts ${fin.map(x => x.count).join('/')} sinks ${fin.map(x => x.sink).join(' ')}`);
const nodes = [n0, n1b, n2, n3, n4];
const rejectLines = nodes.map(n => n.grep(/wrong block version|got reject message|Rejected block|PoW rejected/i).map(l => l.replace(/^.*?\] /, '')).slice(0, 3));
const minerRej = ['old0', 'new1'].map(l => minerLog(l).filter(x => /rejected nonce=|submit error/.test(x)).length);
const accepted = ['old0', 'new1'].map(l => minerLog(l).filter(x => /ACCEPTED block/.test(x)).length);
// the header versions on the chain, from the old hub's view
let versions = {};
try {
const d = await n0.rpc.call('getBlockDagInfo'); let low = d.pruningPointHash; const seen = new Set();
for (let round = 0; round < 200; round++) {
const r = await n0.rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: false });
const blocks = r.blocks || []; let added = 0;
for (const b of blocks) { const h = b.verboseData?.hash || b.header?.hash; if (seen.has(h)) continue; seen.add(h); versions[b.header.version] = (versions[b.header.version] || 0) + 1; added++; }
if (!blocks.length || added === 0) break;
low = (r.blockHashes || []).at(-1) || blocks.at(-1).verboseData?.hash; if (!low) break;
}
} catch (e) { log(`getBlocks: ${e.message}`); }
const digests = nodes.map(n => n.digest());
const maxCount = Math.max(...fin.map(x => x.count || 0));
const checks = {
one_digest_on_old_and_new: new Set(digests).size === 1 && digests[0] != null,
new_miner_blocks_accepted: accepted[1] > 0,
old_miner_blocks_accepted: accepted[0] > 0,
zero_rejected_by_miners: minerRej.every(x => x === 0),
no_wrong_version_or_reject_line: rejectLines.every(r => r.length === 0),
// every mined header carries the block version 2; the devnet genesis header is version 0 and is the one such block
every_header_version_is_the_block_version: Object.keys(versions).length > 0 && Object.keys(versions).every(v => +v === 2 || (+v === 0 && versions[v] === 1)),
old_nodes_hold_the_new_blocks: fin[0].count != null && fin[2].count != null && fin[0].count >= accepted[1] + accepted[0] - 2,
counts_agree_at_the_end: fin.every(x => x.count != null && maxCount - x.count <= 3),
clean_new_node_joined_and_synced: joinedCount != null && hubAtJoin != null && joinedCount >= hubAtJoin - 3 && fin[3].count >= maxCount - 3,
restarted_new_node_resynced: fin[1].count != null && fin[1].count >= maxCount - 3,
new_node_served_a_genesis_sync_and_survived: servingNodeAlive && servedCount != null && servedCount >= hubAtJoin - 3 && fin[4].count >= maxCount - 3,
no_panic_in_any_node_log: nodes.every(n => n.grep(/panicked at|Exiting\.\.\./).length === 0),
};
const pass = Object.values(checks).every(Boolean);
writeFileSync(`${TMP}/summary.json`, JSON.stringify({ pass, checks, new_binary: NEW, old_binary: OLD, digests, counts_after_mining: c, final: fin, served_join_count: servedCount, serving_node_alive: servingNodeAlive, accepted_old_new: accepted, rejected_by_miners_old_new: minerRej, reject_lines: rejectLines, header_versions: versions, secs: SECS }, null, 2));
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'}: digest ${digests[0]?.slice(0, 16)} on ${new Set(digests).size === 1 ? 'all four' : 'NOT all'}; accepted old/new ${accepted.join('/')}, rejected ${minerRej.join('/')}; header versions ${JSON.stringify(versions)}; final counts ${fin.map(x => x.count).join('/')}; reject lines ${rejectLines.map(r => r.length).join('/')}`);
for (const r of rejectLines.flat().slice(0, 4)) log(` BAD ${r.slice(0, 220)}`);
for (const [k, v] of Object.entries(checks)) if (!v) log(`FAILED CHECK ${k}`);
log(`summary: ${TMP}/summary.json`);
await stopAll();
process.exit(pass ? 0 : 1);