From 58fdfda87a710fe5fddde6855a67e6cdf0d86fe3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:59:34 +0000 Subject: [PATCH] Counter ASIC 3.0 gates (node): the 0.3.15 digest rule's compat and refusal cases on real nodes (digest-compat.mjs: a fixed node and a 0.3.14 node on the thirteen-field file peer on one digest; the sixteen-field node is refused), the P3 row, the sixteen-field digest 1dddfa55... on the devnet id Co-Authored-By: Claude Fable 5.1 --- .../digest-compat-20261006.json | 64 ++++++++++++++ docs/plans/counter-asic-3-gate/node-gates.md | 2 + infra/fast-time/digest-compat.mjs | 84 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 docs/plans/counter-asic-3-gate/digest-compat-20261006.json create mode 100644 infra/fast-time/digest-compat.mjs diff --git a/docs/plans/counter-asic-3-gate/digest-compat-20261006.json b/docs/plans/counter-asic-3-gate/digest-compat-20261006.json new file mode 100644 index 00000000..a07042c7 --- /dev/null +++ b/docs/plans/counter-asic-3-gate/digest-compat-20261006.json @@ -0,0 +1,64 @@ +{ + "pass": true, + "checks": { + "new_and_old_on_13_fields_print_one_digest": true, + "the_16_field_digest_differs": true, + "n0_peers_with_n1_and_n2": true, + "old_node_peers_with_new": true, + "n3_has_no_peer": true, + "a_digest_refusal_line_exists": true + }, + "rows": [ + { + "node": "n0", + "binary": "new", + "fields": 13, + "digest": "a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6", + "peers": 2, + "refusal": [ + "Application directory: /tmp/igneum-digest-compat/n0", + "Data directory: /tmp/igneum-digest-compat/n0/igneum-devnet-995/datadir" + ] + }, + { + "node": "n1", + "binary": "new", + "fields": 13, + "digest": "a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6", + "peers": 1, + "refusal": [ + "Application directory: /tmp/igneum-digest-compat/n1", + "Data directory: /tmp/igneum-digest-compat/n1/igneum-devnet-995/datadir" + ] + }, + { + "node": "n2", + "binary": "old", + "fields": 13, + "digest": "a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6", + "peers": 1, + "refusal": [ + "Application directory: /tmp/igneum-digest-compat/n2", + "Data directory: /tmp/igneum-digest-compat/n2/igneum-devnet-995/datadir" + ] + }, + { + "node": "n3", + "binary": "new", + "fields": 16, + "digest": "db9a85f9ff08f72f11f7ba496b3060189c32bdf6cb53e94c810a4cc25585487a", + "peers": 0, + "refusal": [ + "Application directory: /tmp/igneum-digest-compat/n3", + "Data directory: /tmp/igneum-digest-compat/n3/igneum-devnet-995/datadir" + ] + } + ], + "new_binary": "/Users/joshm/Projects/igneum-wt-ca3-v4-node/vendor/igneum-node-0315fix/../igneum-node-ca3v4/target-ca3v4/release/igneumd", + "old_binary": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-0314/release/igneumd", + "secs": 45, + "refusal_lines_rechecked": [ + "Refusing peer 127.0.0.1:51123: consensus params digest mismatch, local a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6 remote db9a85f9ff08f72f11f7ba496b3060189c32bdf6cb53e94c810a4cc25585487a (the peer's override file, environment or build differs)", + "Refusing peer 127.0.0.1:51393: consensus params digest mismatch, local a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6 remote db9a85f9ff08f72f11f7ba496b3060189c32bdf6cb53e94c810a4cc25585487a (the peer's override file, environment or build differs)" + ] +} \ No newline at end of file diff --git a/docs/plans/counter-asic-3-gate/node-gates.md b/docs/plans/counter-asic-3-gate/node-gates.md index ab678e42..ac0cb766 100644 --- a/docs/plans/counter-asic-3-gate/node-gates.md +++ b/docs/plans/counter-asic-3-gate/node-gates.md @@ -17,4 +17,6 @@ | P2 | miner-signalled class activation, implemented behind the override, the fast-time gate with three cases and the failed case | `docs/plans/counter-asic-3-node.md` section 6; `infra/fast-time/class-v4-signal.mjs` | GREEN on the Mac: two of three signalling never flips (7 epochs, 6,166 to 7,583 bps), all three flips at epoch 3 (the first full window, 10,000 bps, the same line on 3 of 3 nodes, the id assertion), nobody signalling flips at the floor epoch 5 and not before; the known-failed case fails on eight checks. Rows and summary files: node doc section 6.4; `class-v4-signal-{no-flip,flip,floor,failed-case}.json` | | G6 (signalling) | the fork change on PC 2 with the igneum-pow feature | the job ids and their lines | GREEN on fork 0562a7f2 (main f39a8eb), three PC 2 jobs under one clearance ("PC 2 open for G6", 17:27Z), the mkdir lock taken 17:28:15Z and released 17:38:58Z, then 17:39:41Z to 17:55:31Z; prover on, app untouched. Job 1 `build-20261006-173017` (the six crates and the app): every build stage ok, the app tests 112 + 26 + 8, but kaspa-consensus 97 passed and 1 FAILED on 2.0's known flake `ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list` (`UnexpectedDifficulty(.., 487111630, 487128802)` in `mine_on_all`, the 0.3.10 cut's section 11 case, which also passed on the Mac and in this morning's job 155958), and cargo stopped the unit there. Treated as 2.0 did: job 2 `build-20261006-174027` (kaspa-consensus alone, 17:40 to 17:46Z): `RESULT test node [kaspa-consensus] exit 0 19 s`, `done exit 0 after 345 s ... every stage ok`. Job 3 `build-20261006-174823` (the five crates and the app, 17:48 to 17:55Z): `RESULT test node [kaspa-consensus-core igneum-exec kaspa-pow igneum-miner kaspa-p2p-flows] exit 0 35 s` and `RESULT test app/igneum-app [igneum-app] exit 0 5 s`: kaspa-consensus-core 110 + 7 (`override_params_carry_the_program_class_v4_activation ... ok`, the signal window test inside it), igneum-exec 18, kaspa-pow 15 (`program_class_signal_rule ... ok` is consensus-core's; kaspa-pow's `program_class_v4_seeds_hash_the_shadow_program_over_the_v3_day_cache ... ok`, the feature on), igneum-miner 18, kaspa-p2p-flows 33, igneum-app 112 + 26 + 8; the job's own closing line reads `failed ... upload incomplete` because the relay's blob service refused one of the nine uploads (`igneum-app.exe.zst FAILED: blob PUT: no url in the reply: service_unavailable`, the shipper's 17:25Z fault) AFTER both test stages had closed with exit 0, so the STAGE and RESULT lines are the evidence, as the shipper's warning said. Where the runs went: these three on PC 2 under the clearance given before the build-server rule arrived (18:0xZ); no further Linux build or suite is needed by this brief; the next one from this lane goes to igneum-build-1 through tools/build-remote.sh | +| 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. Summary `digest-compat-20261006.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). diff --git a/infra/fast-time/digest-compat.mjs b/infra/fast-time/digest-compat.mjs new file mode 100644 index 00000000..9a62749f --- /dev/null +++ b/infra/fast-time/digest-compat.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// The 0.3.15 digest rule's two cases (docs/plans/counter-asic-3-node.md section 6.2; main's ruling of 6 October 2026, +// 20:1x UK): an absent class v4 field contributes nothing to the consensus digest. Four nodes on a private network +// (suffix 995, ports 29800 and up, data /tmp/igneum-digest-compat), all on the same thirteen-field live devnet object +// COPIED from /tmp/igneum-devnet/override-v3.json (the live file is never written) except the last: +// n0 the fixed (0.3.15) node, thirteen fields the hub every other node connects to +// n1 the fixed node, thirteen fields must peer with n0 (same binary, same file) +// n2 the OLD node (0.3.13 or 0.3.14), thirteen fields the COMPAT case: must peer with n0 (same digest across binaries) +// n3 the fixed node, SIXTEEN fields (the exec pin, the floor, the window) the REFUSAL case: must NOT peer (its digest moved) +// PASS = n0 has n1 and n2 as peers, n3 has none and its log (or n0's) carries the digest refusal. The refusal case is +// the rule's own known-failed case: the fields present DO move the digest. +// node infra/fast-time/digest-compat.mjs --new --old [--secs 40] + +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'); +const SECS = +(sflag('secs') || 40); +if (!NEW || !OLD || !existsSync(NEW) || !existsSync(OLD)) { console.error('usage: --new --old '); process.exit(2); } +const TMP = '/tmp/igneum-digest-compat'; +const BASE = 29800, SUFFIX = 995; +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 }); +// the live thirteen-field object, read (never written) from the Mac node's file; the sixteen-field one adds the exec pin, +// the floor and the window as text (the never heights must not pass through a JavaScript number) +const live13 = readFileSync('/tmp/igneum-devnet/override-v3.json', 'utf8').trim(); +if (!/^\{.*\}$/s.test(live13) || (live13.match(/":/g) || []).length !== 13) { console.error(`the live file is not a thirteen-field object: ${live13.slice(0, 80)}`); process.exit(2); } +const file13 = `${TMP}/override-13.json`; writeFileSync(file13, live13 + '\n'); +const file16 = `${TMP}/override-16.json`; +writeFileSync(file16, live13.replace(/\}\s*$/, ',"exec_restart_state_root":"0xed27bb2d6128daf50493ed5539a73bb93f9d7c63ad7f70a7cbb46ba81c9e164e","program_class_v4_activation_daa":219600,"program_class_v4_signal_window_daa":86400}\n')); +const started = []; +class Node { + constructor(i, bin, file, connect) { this.i = i; this.bin = bin; this.file = file; this.connect = connect; this.grpc = 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`; } + async start() { + mkdirSync(this.dir, { recursive: true }); + const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--utxoindex', `--appdir=${this.dir}`, + `--rpclisten=127.0.0.1:${this.grpc}`, `--rpclisten-json=127.0.0.1:${this.json}`, `--listen=127.0.0.1:${this.p2p}`, `--override-params-file=${this.file}`, '--loglevel=info', '--yes']; + if (this.connect) a.push(`--connect=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; + } + 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 peers() { try { const r = await this.rpc.call('getConnectedPeerInfo', {}); const list = r.peerInfo || r.peer_info || r.infos || r; return Array.isArray(list) ? list.length : null; } catch (e) { return `rpc: ${e.message}`; } } +} +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('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); }); + +const n0 = await new Node(0, NEW, file13, null).start(); +const n1 = await new Node(1, NEW, file13, n0.p2p).start(); +const n2 = await new Node(2, OLD, file13, n0.p2p).start(); +const n3 = await new Node(3, NEW, file16, n0.p2p).start(); +const nodes = [n0, n1, n2, n3]; +await sleep(SECS * 1000); +const rows = []; +for (const n of nodes) { + const peers = await n.peers(); + // the handshake's own line (flow_context.rs): `Refusing peer : consensus params digest mismatch, local remote ` + const refusal = n.grep(/Refusing peer .*digest/).map(l => l.replace(/^.*?\] /, '')).slice(0, 2); + rows.push({ node: `n${n.i}`, binary: n.bin === NEW ? 'new' : 'old', fields: n.file === file13 ? 13 : 16, digest: n.digest(), peers, refusal }); + log(`n${n.i} ${n.bin === NEW ? 'new' : 'old'} ${n.file === file13 ? 13 : 16} fields: digest ${n.digest()}, peers ${peers}${refusal.length ? `, digest lines: ${refusal.join(' | ').slice(0, 300)}` : ''}`); +} +const checks = { + new_and_old_on_13_fields_print_one_digest: rows[0].digest && rows[0].digest === rows[1].digest && rows[0].digest === rows[2].digest, + the_16_field_digest_differs: rows[3].digest && rows[3].digest !== rows[0].digest, + n0_peers_with_n1_and_n2: rows[0].peers === 2, + old_node_peers_with_new: rows[2].peers === 1, + n3_has_no_peer: rows[3].peers === 0, + a_digest_refusal_line_exists: rows[3].refusal.length > 0 || rows[0].refusal.length > 0, +}; +const pass = Object.values(checks).every(Boolean); +writeFileSync(`${TMP}/summary.json`, JSON.stringify({ pass, checks, rows, new_binary: NEW, old_binary: OLD, secs: SECS }, null, 2)); +log(`SUMMARY ${pass ? 'PASS' : 'FAIL'}: COMPAT new-13 ${rows[0].digest?.slice(0, 16)} = old-13 ${rows[2].digest?.slice(0, 16)} (n0 peers ${rows[0].peers}, old node peers ${rows[2].peers}); REFUSAL new-16 ${rows[3].digest?.slice(0, 16)} peers ${rows[3].peers}`); +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);