From 9066cd9f73487804ce455497c655d8edb9a32678 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:42:45 +0000 Subject: [PATCH] pool/tools/dn3-split-read.mjs: the Devnet 3 read for the pool split's activation (the hour's first and last chain block, blocks carrying IGNS and IGNP, exec roots across the nodes at one executed height, the implied height) Co-Authored-By: Claude Fable 5.1 (cherry picked from commit a9de830df2827a3ff47fbc1cdf29bb85186b3ea8) --- pool/tools/dn3-split-read.mjs | 113 ++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 pool/tools/dn3-split-read.mjs diff --git a/pool/tools/dn3-split-read.mjs b/pool/tools/dn3-split-read.mjs new file mode 100644 index 000000000..3c1747c73 --- /dev/null +++ b/pool/tools/dn3-split-read.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// The Devnet 3 read for the pool split's activation (the node lane's 6.11 condition, 7 October 2026): over a window of +// chain blocks on the hub node, the first and last chain block (number, hash, UTC time), the blocks whose coinbase +// carries the share chain's commitment (IGNS) and the window's split (IGNP), and the executed state root of the window's +// last block on every node named, so the node lane can set `pool_split_activation_daa` at the hour's last block + 7,200. +// Reads chain-side facts only (getVirtualChainFromBlock, getBlock with transactions, igneum_getExecStatus and +// eth_getBlockByNumber on the exec RPC); never a process name, never a reported rate. +// +// node pool/tools/dn3-split-read.mjs --hub ws://dn3-g1:28610 --hub-exec http://dn3-g1:26790 \ +// --node ws://:28610=http://:26790 --node ws://:28610=http://:26790 \ +// --from-daa [--to-daa ] [--out ] +// +// The hub's chain between the two DAA scores is walked from the pruning point or a block below `--from-daa` through +// getVirtualChainFromBlock; every chain block and its mergeset blues are read with transactions; the coinbase payload +// (hex) is scanned for the tags. Exec roots: igneum_getExecStatus on every node (executed tip, hash), then +// eth_getBlockByNumber at the lowest executed tip across the nodes, stateRoot compared. + +import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs'; +import { writeFileSync } from 'node:fs'; + +const args = process.argv.slice(2); +const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : d; }; +const all = (n) => args.map((a, i) => (a === n ? args[i + 1] : null)).filter(Boolean); +const HUB = opt('--hub', 'ws://127.0.0.1:28610'); +const HUB_EXEC = opt('--hub-exec', 'http://127.0.0.1:26790'); +const NODES = all('--node').map(s => { const [ws, exec] = s.split('='); return { ws, exec }; }); +const FROM = +opt('--from-daa', 0); +const TO = opt('--to-daa', null) == null ? null : +opt('--to-daa'); +const OUT = opt('--out', null); +const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a); + +let rpcId = 0; +async function execCall(url, method, params = []) { + const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }) }); + const j = await r.json(); if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`); return j.result; +} +const hexToBytes = (h) => Buffer.from(h.replace(/^0x/, ''), 'hex'); +const hasTag = (payloadHex, tag) => hexToBytes(payloadHex).includes(Buffer.from(tag, 'ascii')); +// the split's entries, from IGNP || 4 hex count || count x (40 hex address || 8 hex weight) (consensus/core/src/evm.rs) +function splitOf(payloadHex) { + const b = hexToBytes(payloadHex); const i = b.indexOf(Buffer.from('IGNP', 'ascii')); if (i < 0) return null; + const body = b.subarray(i + 4).toString('latin1'); const n = parseInt(body.slice(0, 4), 16); if (!(n > 0)) return null; + const out = []; for (let k = 0; k < n; k++) { const e = body.slice(4 + k * 48, 4 + k * 48 + 48); if (e.length < 48) return null; out.push({ address: '0x' + e.slice(0, 40), weight: parseInt(e.slice(40, 48), 16) }); } + return out; +} + +const hub = await connectRpc(HUB, { attempts: 10, waitMs: 1000 }); +if (!hub) { console.error(`no RPC at ${HUB}`); process.exit(2); } +const info = await hub.call('getBlockDagInfo', {}); +log(`hub ${HUB}: network ${info.networkName || info.network}, virtual DAA ${info.virtualDaaScore}, pruning point ${String(info.pruningPointHash).slice(0, 16)}`); +const toDaa = TO ?? Number(info.virtualDaaScore); +// walk the selected chain from the pruning point (the hub's own view), keeping chain blocks within the DAA window +let cursor = info.pruningPointHash; +const chainBlocks = []; let steps = 0; +while (steps++ < 2000) { + const r = await hub.call('getVirtualChainFromBlock', { startHash: cursor, includeAcceptedTransactionIds: false }); + const added = r.addedChainBlockHashes || []; + if (!added.length) break; + for (const h of added) { + const b = (await hub.call('getBlock', { hash: h, includeTransactions: true })).block; + const daa = Number(b.header.daaScore); + if (daa >= FROM && daa <= toDaa) chainBlocks.push(b); + if (daa > toDaa) { steps = 1e9; break; } + } + cursor = added[added.length - 1]; + if (added.length < 2) break; +} +if (!chainBlocks.length) { console.error(`no chain blocks between DAA ${FROM} and ${toDaa} on the hub`); process.exit(3); } +// every block the window pays: the chain blocks and their mergeset blues +const seen = new Set(); const blocks = []; +for (const cb of chainBlocks) { + const blues = [cb.verboseData?.hash || cb.header.hash, ...(cb.verboseData?.mergeSetBluesHashes || [])]; + for (const h of blues) { + if (seen.has(h)) continue; seen.add(h); + const b = h === (cb.verboseData?.hash || cb.header.hash) ? cb : (await hub.call('getBlock', { hash: h, includeTransactions: true })).block; + const cbTx = (b.transactions || [])[0]; + const payload = cbTx?.payload || ''; + const split = splitOf(payload); + blocks.push({ hash: h, daa: Number(b.header.daaScore), ts: Number(b.header.timestamp), chain: b === cb, igns: hasTag(payload, 'IGNS'), ignp: !!split, igna: hasTag(payload, 'IGNA'), split_entries: split ? split.length : 0, vote_key_hash: b.header.voteKeyHash || b.header.vote_key_hash }); + } +} +const first = chainBlocks[0], last = chainBlocks[chainBlocks.length - 1]; +const iso = (ms) => new Date(Number(ms)).toISOString(); +const withBoth = blocks.filter(b => b.igns && b.ignp); +const onlyOne = blocks.filter(b => b.igns !== b.ignp); +log(`window: ${chainBlocks.length} chain blocks, ${blocks.length} blue blocks; first chain block DAA ${first.header.daaScore} ${String(first.verboseData?.hash || first.header.hash).slice(0, 16)} at ${iso(first.header.timestamp)}; last DAA ${last.header.daaScore} ${String(last.verboseData?.hash || last.header.hash).slice(0, 16)} at ${iso(last.header.timestamp)}`); +log(`blocks carrying IGNS and IGNP: ${withBoth.length} (${withBoth.filter(b => b.chain).length} chain blocks); one tag without the other: ${onlyOne.length}; distinct vote keys among the split blocks: ${new Set(withBoth.map(b => b.vote_key_hash)).size}; split entries min/max: ${withBoth.length ? Math.min(...withBoth.map(b => b.split_entries)) : 0}/${withBoth.length ? Math.max(...withBoth.map(b => b.split_entries)) : 0}`); +// exec roots across the nodes at one executed height +const nodes = [{ name: 'hub', ws: HUB, exec: HUB_EXEC }, ...NODES.map((n, i) => ({ name: `node${i + 1}`, ...n }))]; +const statuses = []; +for (const n of nodes) { try { const s = await execCall(n.exec, 'igneum_getExecStatus', []); statuses.push({ name: n.name, exec: n.exec, tip: Number(s.executedTip), tipHash: s.executedTipHash, tipDaa: Number(s.executedTipDaa) }); } catch (e) { statuses.push({ name: n.name, exec: n.exec, error: e.message }); } } +const lowest = Math.min(...statuses.filter(s => s.tip != null).map(s => s.tip)); +const roots = []; +for (const s of statuses) { + if (s.tip == null) { roots.push({ name: s.name, error: s.error }); continue; } + try { const b = await execCall(s.exec, 'eth_getBlockByNumber', ['0x' + lowest.toString(16), false]); roots.push({ name: s.name, number: lowest, hash: b?.hash, stateRoot: b?.stateRoot }); } catch (e) { roots.push({ name: s.name, number: lowest, error: e.message }); } +} +const distinctRoots = new Set(roots.filter(r => r.stateRoot).map(r => r.stateRoot)); +log(`exec: ${statuses.map(s => `${s.name} tip ${s.tip ?? 'ERR'}`).join(', ')}; state root at executed block ${lowest} on ${roots.filter(r => r.stateRoot).length} nodes: ${distinctRoots.size === 1 ? 'AGREE ' + [...distinctRoots][0].slice(0, 18) : 'DISAGREE ' + roots.map(r => `${r.name}=${String(r.stateRoot).slice(0, 12)}`).join(' ')}`); +const result = { + read_at: new Date().toISOString(), hub: HUB, network: info.networkName || info.network, from_daa: FROM, to_daa: toDaa, + first_chain_block: { number_daa: Number(first.header.daaScore), blue_score: Number(first.header.blueScore), hash: first.verboseData?.hash || first.header.hash, time: iso(first.header.timestamp) }, + last_chain_block: { number_daa: Number(last.header.daaScore), blue_score: Number(last.header.blueScore), hash: last.verboseData?.hash || last.header.hash, time: iso(last.header.timestamp) }, + activation_daa_if_set_now: Number(last.header.daaScore) + 7200, + chain_blocks: chainBlocks.length, blue_blocks: blocks.length, blocks_with_igns_and_ignp: withBoth.length, chain_blocks_with_split: withBoth.filter(b => b.chain).length, blocks_with_one_tag_only: onlyOne.length, + distinct_vote_keys_in_split_blocks: new Set(withBoth.map(b => b.vote_key_hash)).size, + exec: { statuses, roots_at: lowest, roots, agree: distinctRoots.size === 1 }, + split_blocks: withBoth.map(b => ({ hash: b.hash, daa: b.daa, time: iso(b.ts), chain: b.chain, entries: b.split_entries })), + source: 'hub getBlockDagInfo, getVirtualChainFromBlock, getBlock(includeTransactions) coinbase payload tags; every node igneum_getExecStatus and eth_getBlockByNumber at the lowest executed tip', +}; +if (OUT) { writeFileSync(OUT, JSON.stringify(result, null, 2)); log(`written ${OUT}`); } +console.log(JSON.stringify({ ...result, split_blocks: result.split_blocks.length }, null, 1)); +process.exit(0);