Section 7.5: five runs recorded. The first idled nothing and never asked the gate; the second found the depth gap (fixed 3301cf32); the third found the extension shortcut (fixed aa0182aa: a candidate on the sink's chain is not let through, the depth along its own chain decides); the fourth, on aa0182aa, PASSES (B 2,750 bps at the fork, 183 DAA deep, heavier by 183, A kept its chain for 150 s and learned 177 of B's blocks, 268 refusal lines, one per refused block); the known-failed case PASSES (gate off, A reorged onto a 2,083 bps key's 162-DAA chain 25 s after the heal); the harness's failed shape FAILS as it must. Three harness faults fixed on the way (shared ports, an RPC call before the socket opened, a reorg test by key instead of by chain) and one build fault (ledger N5, the stub-engine node). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
237 lines
16 KiB
JavaScript
237 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
// Weight-gated deep fork choice (docs/analysis/51-percent.md rank 2; fork `deep_fork_refusal`, 0.3.16): the fast-time
|
|
// two-node case. Node A (three CPU threads) and node B (one thread) mine together for --joint seconds, so the weight
|
|
// table reads about A 75 percent, B 25 percent. Then the link is cut (a pass-through proxy B dials is closed), A's
|
|
// miner STOPS (the honest side idles, as a withheld chain's victim does not see it) and B mines a private chain with
|
|
// four threads for --split seconds, more than the gate's window (--window DAA, 60 here against the devnet's 600 s),
|
|
// so B's chain is heavier by the whole split. Then the link is restored, A's miner restarts and A's sink is watched.
|
|
// The first run (22:03Z, 6 October 2026) kept A mining through the split: B ended one block heavier, A's sink search
|
|
// never popped B's tip and the gate was never asked (A held, 0 refusal lines); the stopped-A shape asks it.
|
|
//
|
|
// --gate on (fork_gate_activation_daa 0) A keeps its own chain: its sink's builder stays A's key for --watch
|
|
// seconds and A's log carries "Fork choice: block ... not a sink candidate"; B, whose chain is heavier,
|
|
// is not reorged either (a sub-third side cannot reorg the other past the window, the intended outcome)
|
|
// --gate off (the switch at never) the known-failed case: within --watch seconds A's sink is a block
|
|
// built by B's key (51-percent.md section 1: a heavier deep fork wins under the rule as written)
|
|
// --expect hold|reorg says which outcome is a PASS; `--gate off --expect hold` is the harness's own failed case.
|
|
//
|
|
// node infra/fast-time/fork-gate.mjs --gate on --expect hold [--joint 240] [--split 180] [--watch 150] [--window 60]
|
|
// IGNEUMD, IGNEUM_MINER name the binaries (defaults: vendor/igneum-node-0316/target-0316/release).
|
|
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { createServer, connect as netConnect } from 'node:net';
|
|
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
|
|
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
|
|
|
|
const ROOT = new URL('../../', import.meta.url).pathname;
|
|
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
|
|
const BIN = process.env.IGNEUM_0316_BIN || `${ROOT}vendor/igneum-node-0316/target-0316/release`;
|
|
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
|
|
const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
|
|
const TMP = '/tmp/igneum-fast-time-fg';
|
|
const BASE = 30090, SUFFIX = 989; // 30090 to 30112 and the link at 30191: clear of difficulty-v3.mjs (29790s, proxies 29891 and 29892) and vote-or-burn.mjs (29990s)
|
|
const NEVER = '18446744073709551615';
|
|
const args = process.argv.slice(2);
|
|
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? Number(args[i + 1]) : dflt; };
|
|
const sflag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
|
|
const GATE = sflag('gate') || 'on';
|
|
const EXPECT = sflag('expect') || (GATE === 'on' ? 'hold' : 'reorg');
|
|
const JOINT = flag('joint', 240), SPLIT = flag('split', 180), WATCH = flag('watch', 150), WINDOW = flag('window', 60);
|
|
const GENESIS_BITS = flag('genesis-bits', 0x1f010000);
|
|
const CASE = sflag('case') || `gate-${GATE}-expect-${EXPECT}`;
|
|
const OUT = sflag('out') || `${ROOT}docs/plans/counter-asic-3-gate/fork-gate-${CASE}.json`;
|
|
if (!['on', 'off'].includes(GATE) || !['hold', 'reorg'].includes(EXPECT)) { console.error('usage: --gate on|off --expect hold|reorg'); process.exit(2); }
|
|
const started = [];
|
|
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
|
// nodes of an earlier case that died mid-run hold the ports: kill them by their devnet suffix before anything binds
|
|
try { spawnSync('pkill', ['-f', `devnet-suffix=${SUFFIX}`]); } catch { }
|
|
await sleep(1500);
|
|
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
|
|
|
const baseText = readFileSync(FILE, 'utf8');
|
|
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
|
|
export function mergeOverrideText(text, fields) {
|
|
let out = text;
|
|
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
|
|
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', ');
|
|
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
|
|
}
|
|
const DAY_MS = field('pow_day_ms');
|
|
const override = `${TMP}/override.json`;
|
|
writeFileSync(override, mergeOverrideText(baseText, {
|
|
genesis_bits: GENESIS_BITS, skip_proof_of_work: false,
|
|
fork_gate_activation_daa: GATE === 'on' ? '0' : NEVER, fork_gate_window_daa: WINDOW,
|
|
program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: NEVER,
|
|
}));
|
|
log(`case ${CASE}: gate ${GATE} (window ${WINDOW} DAA), joint ${JOINT} s, split ${SPLIT} s, watch ${WATCH} s, expect ${EXPECT}`);
|
|
|
|
// a pass-through proxy: open = the link is up; closed = the partition
|
|
class Link {
|
|
constructor(listenPort, targetPort) { this.listenPort = listenPort; this.targetPort = targetPort; this.socks = new Set(); this.up = false; }
|
|
open() {
|
|
return new Promise((resolve) => {
|
|
this.server = createServer((client) => {
|
|
const upstream = netConnect(this.targetPort, '127.0.0.1');
|
|
this.socks.add(client); this.socks.add(upstream);
|
|
client.pipe(upstream); upstream.pipe(client);
|
|
for (const s of [client, upstream]) { s.on('error', () => { }); s.on('close', () => { this.socks.delete(s); if (!client.destroyed) client.destroy(); if (!upstream.destroyed) upstream.destroy(); }); }
|
|
});
|
|
this.server.listen(this.listenPort, '127.0.0.1', () => { this.up = true; resolve(this); });
|
|
});
|
|
}
|
|
close() {
|
|
return new Promise((resolve) => {
|
|
this.up = false;
|
|
for (const s of this.socks) { try { s.destroy(); } catch { } }
|
|
this.socks.clear();
|
|
if (this.server) this.server.close(() => resolve()); else resolve();
|
|
this.server = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
class Node {
|
|
constructor(i, connect = null) {
|
|
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2;
|
|
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
|
|
}
|
|
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
|
|
async start() {
|
|
mkdirSync(this.dir, { recursive: true });
|
|
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
|
|
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`,
|
|
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
|
// --addpeer, not --connect: a dropped addpeer is dialled again, which is how the link heals
|
|
if (this.connect) a.push(`--addpeer=127.0.0.1:${this.connect}`); else a.push('--outpeers=0');
|
|
const out = openSync(this.logFile, 'a');
|
|
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
|
|
started.push(this.proc);
|
|
await sleep(1500);
|
|
if (this.proc.exitCode != null) throw new Error(`n${this.i} exited ${this.proc.exitCode}: ${this.grepLog(/ERROR|Error|error|refused|invalid/).slice(-3).join(' | ')}`);
|
|
// the RPC must answer before the node is used: the 23:17Z run called it one tick after the socket opened and got
|
|
// "rpc not connected", and the nodes it left behind took the next case's ports
|
|
for (let i = 0; i < 20; i++) {
|
|
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`); await this.rpc.call('getBlockDagInfo'); break; } catch (e) { this.rpc = null; await sleep(500); }
|
|
}
|
|
if (!this.rpc) throw new Error(`n${this.i}: the RPC did not answer within 10 s`);
|
|
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}${this.connect ? ` addpeer via link ${this.connect}` : ''}`);
|
|
return this;
|
|
}
|
|
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
|
async sink() {
|
|
const d = await this.rpc.call('getBlockDagInfo');
|
|
const b = await this.rpc.call('getBlock', { hash: d.sink, includeTransactions: false });
|
|
return { hash: d.sink, blocks: d.blockCount, daa: +b.block.header.daaScore, blue: +b.block.header.blueScore, key: String(b.block.header.voteKeyHash) };
|
|
}
|
|
}
|
|
let miners = {};
|
|
function miner(name, grpc, threads, secs, label = name) {
|
|
// `label` is the vote-key label (the key); `name` the log file and the handle, so a restarted miner keeps its key
|
|
const out = openSync(`${TMP}/${name}.log`, 'a');
|
|
const p = spawn(CPU_MINER, ['mine', grpc, String(threads), String(secs), label, '--engine', 'igneum-pow', '--payout-label', label, '--status-secs', '30', '--no-vote'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
|
|
started.push(p); miners[name] = p;
|
|
return p;
|
|
}
|
|
const minerKey = (name) => { try { const m = /vote_key_hash=([0-9a-f]{64})/.exec(readFileSync(`${TMP}/${name}.log`, 'utf8')); return m ? m[1] : null; } catch { return null; } };
|
|
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 { } }
|
|
try { await link.close(); } catch { }
|
|
}
|
|
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
|
|
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
|
|
|
const a = await new Node(0).start();
|
|
const link = await new Link(BASE + 101, a.p2pPort).open();
|
|
const b = await new Node(1, link.listenPort).start();
|
|
await sleep(3000);
|
|
const t0 = Date.now();
|
|
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
|
|
|
// phase 1: together
|
|
miner('fg-a', a.grpc, 3, JOINT + 60);
|
|
miner('fg-b', b.grpc, 1, JOINT);
|
|
await sleep(JOINT * 1000);
|
|
const keyA = minerKey('fg-a'), keyB = minerKey('fg-b');
|
|
const joint = { a: await a.sink(), b: await b.sink() };
|
|
log(`phase 1 done at ${since()} s: A ${joint.a.blocks} blocks sink ${joint.a.hash.slice(0, 8)} daa ${joint.a.daa}; B ${joint.b.blocks} blocks sink ${joint.b.hash.slice(0, 8)}; keys A ${keyA?.slice(0, 8)} B ${keyB?.slice(0, 8)}`);
|
|
// the weight share at the fork: builders of the last WINDOW_W blocks of A's chain by key, from A's blocks
|
|
async function shareOfB(node, upTo) {
|
|
let cur = upTo, nA = 0, nB = 0;
|
|
for (let i = 0; i < field('weight_window') || i < 120; i++) {
|
|
const blk = await node.rpc.call('getBlock', { hash: cur, includeTransactions: false });
|
|
const k = String(blk.block.header.voteKeyHash);
|
|
for (const h of [cur, ...(blk.block.verboseData?.mergeSetBluesHashes || []).filter(x => x !== blk.block.verboseData?.selectedParentHash)]) {
|
|
const hb = h === cur ? blk : await node.rpc.call('getBlock', { hash: h, includeTransactions: false });
|
|
const kk = String(hb.block.header.voteKeyHash);
|
|
if (kk === keyB) nB++; else if (kk === keyA) nA++;
|
|
}
|
|
const sp = blk.block.verboseData?.selectedParentHash; if (!sp || +blk.block.header.daaScore <= 1) break; cur = sp;
|
|
void k;
|
|
}
|
|
return { a: nA, b: nB, share_b_bps: nA + nB ? Math.round(10000 * nB / (nA + nB)) : null };
|
|
}
|
|
const share = await shareOfB(a, joint.a.hash);
|
|
log(`weight at the fork (last ${share.a + share.b} blue blocks of A's chain): A ${share.a}, B ${share.b}, B ${share.share_b_bps} bps`);
|
|
|
|
// phase 2: the partition; A idles, B mines heavier
|
|
await link.close();
|
|
try { miners['fg-b'].kill('SIGINT'); } catch { }
|
|
try { miners['fg-a'].kill('SIGINT'); } catch { }
|
|
await sleep(2000);
|
|
miner('fg-b4', b.grpc, 4, SPLIT + WATCH + 60, 'fg-b');
|
|
log(`link cut at ${since()} s; A's miner stopped, B now mines with 4 threads`);
|
|
await sleep(SPLIT * 1000);
|
|
const split = { a: await a.sink(), b: await b.sink() };
|
|
log(`phase 2 done at ${since()} s: A ${split.a.blocks} blocks sink ${split.a.hash.slice(0, 8)} daa ${split.a.daa} blue ${split.a.blue}; B ${split.b.blocks} blocks sink ${split.b.hash.slice(0, 8)} daa ${split.b.daa} blue ${split.b.blue} (B heavier: ${split.b.blue > split.a.blue})`);
|
|
const forkDepthAtHeal = split.b.daa - joint.a.daa; // the fork point is A's tip at the cut; B's chain since then is the deep side
|
|
|
|
// phase 3: heal, A mines again, watch
|
|
await link.open();
|
|
miner('fg-a2', a.grpc, 3, WATCH + 60, 'fg-a');
|
|
log(`link restored at ${since()} s, A's miner restarted; watching A's sink for ${WATCH} s (fork depth about ${forkDepthAtHeal} DAA, window ${WINDOW})`);
|
|
const samples = [];
|
|
let reorgAt = null;
|
|
const healAt = Date.now();
|
|
// a reorg is A's sink leaving its pre-cut chain for B's: a block B mined INTO the shared chain before the cut carries
|
|
// B's key and is A's own (the 23:37Z run read A's pre-cut sink as "B-built"), so the test is the chain, not the key:
|
|
// the sink is B's when it is not the pre-cut sink and the pre-cut sink is not its chain ancestor on A's own chain,
|
|
// read as: the sink's blue score exceeds the pre-cut blue score by more than A's own mining could add AND its key is B's
|
|
const preCut = split.a;
|
|
while (Date.now() - healAt < WATCH * 1000) {
|
|
await sleep(5000);
|
|
const sa = await a.sink(), sb = await b.sink();
|
|
const onBChain = sa.hash !== preCut.hash && sa.hash === sb.hash; // A and B share a sink that is not A's pre-cut tip: A took B's chain
|
|
const builtByB = onBChain || (sa.hash !== preCut.hash && sa.key === keyB && sa.blue > preCut.blue + 20);
|
|
samples.push({ t: +since(), a: { blocks: sa.blocks, sink: sa.hash.slice(0, 16), daa: sa.daa, blue: sa.blue, by_b: builtByB }, b: { blocks: sb.blocks, sink: sb.hash.slice(0, 16), blue: sb.blue, by_b: sb.key === keyB } });
|
|
if (builtByB && reorgAt == null) { reorgAt = +since(); log(`A's sink is built by B at ${since()} s: ${sa.hash.slice(0, 16)}`); }
|
|
if (samples.length % 6 === 0) log(`t=${since()} s A ${sa.blocks} blocks sink ${sa.hash.slice(0, 8)} by ${builtByB ? 'B' : 'A'} blue ${sa.blue}; B ${sb.blocks} blocks sink ${sb.hash.slice(0, 8)} blue ${sb.blue}`);
|
|
}
|
|
const refusals = a.grepLog(/Fork choice: block .* not a sink candidate/);
|
|
const peers = a.grepLog(/P2P Connected/).length;
|
|
const last = samples.at(-1);
|
|
const held = samples.slice(-6).every(s => !s.a.by_b) && reorgAt == null;
|
|
const bBlocksKnownToA = last ? last.a.blocks - split.a.blocks : 0;
|
|
const checks = {
|
|
b_was_a_minority_at_the_fork: share.share_b_bps != null && share.share_b_bps < 3334,
|
|
fork_deeper_than_window: forkDepthAtHeal > WINDOW,
|
|
b_chain_heavier_at_heal: split.b.blue > split.a.blue,
|
|
a_learned_b_blocks_after_heal: bBlocksKnownToA > 10,
|
|
a_held_its_chain: held,
|
|
refusal_line_on_a: refusals.length > 0,
|
|
a_reorged_to_b: reorgAt != null,
|
|
};
|
|
const good = EXPECT === 'hold'
|
|
? checks.b_was_a_minority_at_the_fork && checks.fork_deeper_than_window && checks.b_chain_heavier_at_heal && checks.a_learned_b_blocks_after_heal && checks.a_held_its_chain && checks.refusal_line_on_a
|
|
: checks.b_was_a_minority_at_the_fork && checks.fork_deeper_than_window && checks.b_chain_heavier_at_heal && checks.a_reorged_to_b;
|
|
const summary = { pass: good, expect: EXPECT, case: CASE, gate: GATE, window: WINDOW, joint: JOINT, split: SPLIT, watch: WATCH, keys: { a: keyA, b: keyB }, share_at_fork: share, joint_sinks: joint, split_sinks: split, fork_depth_daa_at_heal: forkDepthAtHeal, reorg_at_s: reorgAt, refusal_lines: refusals.length, refusal_example: refusals[0]?.replace(/^.*?Fork choice/, 'Fork choice') ?? null, b_blocks_learned_by_a: bBlocksKnownToA, peer_connections_on_a: peers, checks, samples, node: IGNEUMD, miner: CPU_MINER };
|
|
mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true });
|
|
writeFileSync(OUT, JSON.stringify(summary, null, 2));
|
|
const fails = Object.entries(checks).filter(([k, v]) => (EXPECT === 'hold' ? !['a_reorged_to_b'].includes(k) : ['b_was_a_minority_at_the_fork', 'fork_deeper_than_window', 'b_chain_heavier_at_heal', 'a_reorged_to_b'].includes(k)) && !v).map(([k]) => k);
|
|
log(`SUMMARY ${good ? 'PASS' : 'FAIL'} (expect ${EXPECT}, gate ${GATE}): B held ${share.share_b_bps} bps of the blue blocks at the fork; fork depth ${forkDepthAtHeal} DAA against window ${WINDOW}; B blue ${split.b.blue} vs A ${split.a.blue} at heal; A learned ${bBlocksKnownToA} blocks after the heal; A's sink ${reorgAt == null ? 'stayed A-built' : `became B-built at ${reorgAt} s`}; ${refusals.length} refusal lines on A${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`);
|
|
log(`summary: ${OUT}`);
|
|
await stopAll();
|
|
process.exit(good ? 0 : 1);
|