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 <noreply@anthropic.com>
(cherry picked from commit a9de830df2)
This commit is contained in:
igneum-labs 2026-10-07 17:42:45 +00:00
parent 79a6c9a762
commit 9066cd9f73

View file

@ -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://<box-a>:28610=http://<box-a>:26790 --node ws://<box-b>:28610=http://<box-b>:26790 \
// --from-daa <first DAA of the hour> [--to-daa <last DAA>] [--out <json file>]
//
// 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);