igneum/pool/tools/dn3-split-read.mjs
2026-10-07 20:36:20 +00:00

120 lines
10 KiB
JavaScript

#!/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 (IGNH) and the window's split (IGNW), 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>]
// (or --from-ms <unix ms> [--to-ms <unix ms>]: the window by header time, the hour as the fleet lane names it)
//
// 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 FROM_MS = opt('--from-ms', null) == null ? null : +opt('--from-ms');
const TO_MS = opt('--to-ms', null) == null ? null : +opt('--to-ms');
const inWindow = (b) => { const daa = Number(b.header.daaScore), ts = Number(b.header.timestamp); if (FROM_MS != null) return ts >= FROM_MS && (TO_MS == null || ts <= TO_MS); return daa >= FROM && daa <= toDaa; };
const pastWindow = (b) => { const daa = Number(b.header.daaScore), ts = Number(b.header.timestamp); if (FROM_MS != null) return TO_MS != null && ts > TO_MS + 60_000; return daa > toDaa; };
const OUT = opt('--out', null);
// the two tags to look for: IGNH and IGNW since pool-tags-node 7455b8d5; `--tags IGNS,IGNP` reads a chain mined by the
// earlier daemons (the Devnet 3 pair's hour of 7 October 2026, 19:33Z to 20:33Z, ran on the old tags)
const [TAG_CHAIN, TAG_SPLIT] = opt('--tags', 'IGNH,IGNW').split(',');
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 IGNW || 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(TAG_SPLIT, '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;
if (inWindow(b)) chainBlocks.push(b);
if (pastWindow(b)) { steps = 1e9; break; }
}
cursor = added[added.length - 1];
if (added.length < 2) break;
}
if (!chainBlocks.length) { console.error(`no chain blocks in the window (${FROM_MS != null ? `${new Date(FROM_MS).toISOString()} to ${TO_MS != null ? new Date(TO_MS).toISOString() : 'now'}` : `DAA ${FROM} to ${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, ignh: hasTag(payload, TAG_CHAIN), ignw: !!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.ignh && b.ignw);
const onlyOne = blocks.filter(b => b.ignh !== b.ignw);
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 ${TAG_CHAIN} and ${TAG_SPLIT}: ${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, tags: [TAG_CHAIN, TAG_SPLIT], from_daa: FROM, to_daa: toDaa, from_ms: FROM_MS, to_ms: TO_MS,
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_ignh_and_ignw: 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);