From 1fca06423ab95321be4fbf711d9e09e4a1e5d3b8 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:05:54 +0000 Subject: [PATCH] pool/tools/dn3-split-read.mjs from pool-finish-22 c15b39b0 (the Devnet 3 read for the split's activation; the merge did not apply, the one file taken as is) Co-Authored-By: Claude Fable 5.1 --- 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);