Mission item 12: the peer-directory fast-time harness (ten listing nodes announcing their loopback p2p address, every node advertising an unroutable external ip so gossip is dead and the directory is the one live source, a fresh node with one genesis peer watched for 8 outbound), the eclipse simulation (the draw as implemented, without replacement, 34 percent attacker over m keys), and peer_directory_activation_daa in the 60x file
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
2f9a1550d1
commit
9a64d18c15
3 changed files with 256 additions and 1 deletions
|
|
@ -67,5 +67,6 @@
|
|||
"proving_v1_unproven_daa": 10,
|
||||
"proving_v1_aggregator_share_bps": 1000,
|
||||
"fees_v1_activation_daa": 0,
|
||||
"fees": {"pgas": {"version": 1, "cycles_per_pgas": 1000, "intrinsic_pgas_per_tx": 300, "modexp_base": 10, "modexp_per_byte_numer": 1, "modexp_per_byte_denom": 10}, "block_proving_gas_limit": 120000, "shard_proving_gas_budget": 30000, "min_execution_base_fee_wei": 100000000000, "min_proving_base_fee_wei": 10000000000000, "initial_execution_base_fee_wei": 100000000000, "initial_proving_base_fee_wei": 10000000000000, "base_fee_change_denominator": 8}
|
||||
"fees": {"pgas": {"version": 1, "cycles_per_pgas": 1000, "intrinsic_pgas_per_tx": 300, "modexp_base": 10, "modexp_per_byte_numer": 1, "modexp_per_byte_denom": 10}, "block_proving_gas_limit": 120000, "shard_proving_gas_budget": 30000, "min_execution_base_fee_wei": 100000000000, "min_proving_base_fee_wei": 10000000000000, "initial_execution_base_fee_wei": 100000000000, "initial_proving_base_fee_wei": 10000000000000, "base_fee_change_denominator": 8},
|
||||
"peer_directory_activation_daa": null
|
||||
}
|
||||
|
|
|
|||
181
infra/fast-time/peer-directory.mjs
Normal file
181
infra/fast-time/peer-directory.mjs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
#!/usr/bin/env node
|
||||
// Mission item 12, the weight-backed peer directory (docs/analysis/mission/invent.md 7.1), the fast-time gate: "a fresh
|
||||
// node with genesis peers only reaches 8 outbound from the directory". Ten listing nodes mine on one fast-time network,
|
||||
// each miner announcing its own node's loopback p2p address in its blocks (`igneum-miner --announce`); every node advertises
|
||||
// an UNROUTABLE address as its external ip, so ordinary address gossip hands a fresh node dead addresses only and the
|
||||
// directory is the one live source. After --joint seconds (the weight table has every listing key above dust) a fresh node
|
||||
// starts with --outpeers=8 and one --addpeer to node 0 (its "genesis peer"), syncs, reads the announcements out of the
|
||||
// blocks, and is watched for --watch seconds.
|
||||
//
|
||||
// --switch on peer_directory_activation_daa 0: the fresh node reaches 8 outbound peers; its log carries "drawn from
|
||||
// the peer directory"
|
||||
// --switch off the switch at never, the known-failed case: the fresh node holds 1 outbound (node 0) for the whole watch
|
||||
// --expect eight|one which outcome is a PASS; `--switch off --expect eight` is the harness's own failed shape (must FAIL)
|
||||
//
|
||||
// node infra/fast-time/peer-directory.mjs --switch on --expect eight [--listing 10] [--joint 200] [--watch 180] [--slot 0]
|
||||
// IGNEUMD and IGNEUM_MINER name the binaries (a build of the peer-directory-node branch).
|
||||
//
|
||||
// Leftovers of an earlier run of the same slot are stopped by PID FILE, never by name (CLAUDE.md, 6 October 2026).
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, appendFileSync } from 'node:fs';
|
||||
import { Rpc } 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 IGNEUMD = process.env.IGNEUMD || `${ROOT}vendor/igneum-node/target-integration/release/igneumd`;
|
||||
const CPU_MINER = process.env.IGNEUM_MINER || `${ROOT}vendor/igneum-node/target-integration/release/igneum-miner`;
|
||||
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, dflt = null) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
|
||||
const SWITCH = sflag('switch', 'on');
|
||||
const EXPECT = sflag('expect', SWITCH === 'on' ? 'eight' : 'one');
|
||||
const LISTING = flag('listing', 10), JOINT = flag('joint', 200), WATCH = flag('watch', 180), SLOT = flag('slot', 0);
|
||||
const GENESIS_BITS = flag('genesis-bits', 0x1f010000);
|
||||
const CASE = sflag('case') || `switch-${SWITCH}-expect-${EXPECT}`;
|
||||
const OUT = sflag('out') || `${ROOT}docs/plans/mission-item-12-gate/peer-directory-${CASE}.json`;
|
||||
// nodes at BASE + i x 10 (gRPC, p2p, JSON-RPC, EVM RPC), the fresh node last; a slot is 200 ports wide, clear of every other
|
||||
// fast-time harness (the fork gate's slots end at 30930)
|
||||
const BASE = 31090 + SLOT * 200, SUFFIX = 975 + SLOT;
|
||||
const TMP = `/tmp/igneum-fast-time-pd${SLOT}`;
|
||||
const NEVER = '18446744073709551615';
|
||||
if (!['on', 'off'].includes(SWITCH) || !['eight', 'one'].includes(EXPECT)) { console.error('usage: --switch on|off --expect eight|one'); process.exit(2); }
|
||||
const started = [];
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), `pd${SLOT}`, ...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); }
|
||||
|
||||
const PIDS = `${TMP}/pids`;
|
||||
function stopLeftovers() {
|
||||
if (!existsSync(PIDS)) return;
|
||||
const ports = Array.from({ length: 200 }, (_, k) => `127.0.0.1:${BASE + k}`);
|
||||
for (const line of readFileSync(PIDS, 'utf8').split('\n').filter(Boolean)) {
|
||||
const pid = Number(line);
|
||||
let cmd = '';
|
||||
try { cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); } catch { continue; }
|
||||
if (!cmd.includes(TMP) && !ports.some(p => cmd.includes(p))) continue;
|
||||
try { process.kill(pid, 'SIGKILL'); log(`stopped leftover pid ${pid} of an earlier run`); } catch { }
|
||||
}
|
||||
}
|
||||
stopLeftovers();
|
||||
await sleep(1000);
|
||||
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
||||
const track = (proc) => { started.push(proc); try { appendFileSync(PIDS, `${proc.pid}\n`); } catch { } };
|
||||
|
||||
const baseText = readFileSync(FILE, 'utf8');
|
||||
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
|
||||
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,
|
||||
peer_directory_activation_daa: SWITCH === 'on' ? '0' : NEVER,
|
||||
program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: NEVER,
|
||||
}));
|
||||
log(`case ${CASE}: switch ${SWITCH}, ${LISTING} listing nodes, joint ${JOINT} s, watch ${WATCH} s, expect ${EXPECT}`);
|
||||
|
||||
class Node {
|
||||
constructor(name, i, peers = [], outpeers = null) {
|
||||
this.name = name; this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
|
||||
this.peers = peers; this.outpeers = outpeers; this.dir = `${TMP}/${name}`; this.logFile = `${this.dir}/node.log`;
|
||||
}
|
||||
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
// the advertised address is unroutable on purpose (10.255.0.0/16 is not on any route from the box), so gossip is dead
|
||||
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}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
||||
`--listen=127.0.0.1:${this.p2pPort}`, `--externalip=10.255.${Math.floor(this.i / 250)}.${1 + (this.i % 250)}:${this.p2pPort}`,
|
||||
`--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
||||
for (const p of this.peers) a.push(`--addpeer=127.0.0.1:${p}`);
|
||||
if (this.outpeers != null) a.push(`--outpeers=${this.outpeers}`); else if (!this.peers.length) a.push('--outpeers=0');
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
|
||||
track(this.proc);
|
||||
await sleep(1500);
|
||||
if (this.proc.exitCode != null) throw new Error(`${this.name} exited ${this.proc.exitCode}: ${this.grepLog(/ERROR|Error|error|refused|invalid/).slice(-3).join(' | ')}`);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const rpc = new Rpc(`ws://127.0.0.1:${this.jsonPort}`);
|
||||
try { if (await rpc.connect()) { await rpc.call('getBlockDagInfo'); this.rpc = rpc; break; } } catch { try { rpc.close(); } catch { } }
|
||||
this.rpc = null; await sleep(500);
|
||||
}
|
||||
if (!this.rpc) throw new Error(`${this.name}: the RPC did not answer within 10 s`);
|
||||
log(`${this.name} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}${this.peers.length ? ` addpeer ${this.peers.join(',')}` : ''}`);
|
||||
return this;
|
||||
}
|
||||
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
||||
async peers_() {
|
||||
const r = await this.rpc.call('getConnectedPeerInfo');
|
||||
const list = r.infos || r.peerInfo || [];
|
||||
return { outbound: list.filter(p => p.isOutbound).length, inbound: list.filter(p => !p.isOutbound).length, total: list.length };
|
||||
}
|
||||
async dag() { const d = await this.rpc.call('getBlockDagInfo'); return { blocks: +d.blockCount, daa: +d.virtualDaaScore }; }
|
||||
}
|
||||
function miner(name, node, threads, secs) {
|
||||
const out = openSync(`${TMP}/${name}.log`, 'a');
|
||||
const p = spawn(CPU_MINER, ['mine', node.grpc, String(threads), String(secs), name, '--engine', 'igneum-pow', '--payout-label', name, '--status-secs', '60', '--no-vote', '--announce', `127.0.0.1:${node.p2pPort}`],
|
||||
{ stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
|
||||
track(p);
|
||||
return p;
|
||||
}
|
||||
async function stopAll() {
|
||||
for (const p of started.slice().reverse()) { try { p.kill('SIGINT'); } catch { } }
|
||||
await sleep(1500);
|
||||
for (const p of started) { try { p.kill('SIGKILL'); } 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); });
|
||||
|
||||
// the listing nodes: node 0 listens, every other dials node 0 and its predecessor (a mesh with two links each)
|
||||
const nodes = [await new Node('n0', 0).start()];
|
||||
for (let i = 1; i < LISTING; i++) nodes.push(await new Node(`n${i}`, i, [nodes[0].p2pPort, nodes[i - 1].p2pPort]).start());
|
||||
await sleep(2000);
|
||||
const t0 = Date.now();
|
||||
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
||||
for (const n of nodes) miner(`pd-${n.name}`, n, 1, JOINT + WATCH + 120);
|
||||
await sleep(JOINT * 1000);
|
||||
const dag0 = await nodes[0].dag();
|
||||
const listed = nodes[0].grepLog(/Peer directory: key .* lists/).length;
|
||||
log(`phase 1 done at ${since()} s: node 0 at ${dag0.blocks} blocks DAA ${dag0.daa}; ${listed} directory listing line(s) on node 0`);
|
||||
|
||||
// the fresh node: one genesis peer, 8 wanted
|
||||
const fresh = await new Node('fresh', LISTING, [nodes[0].p2pPort], 8).start();
|
||||
const samples = [];
|
||||
let eightAt = null;
|
||||
const watchAt = Date.now();
|
||||
while (Date.now() - watchAt < WATCH * 1000) {
|
||||
await sleep(5000);
|
||||
const p = await fresh.peers_();
|
||||
const d = await fresh.dag();
|
||||
const drawn = fresh.grepLog(/drawn from the peer directory/).length;
|
||||
const connectedFromDirectory = fresh.grepLog(/connected to .* from the peer directory/).length;
|
||||
samples.push({ t: +since(), outbound: p.outbound, inbound: p.inbound, blocks: d.blocks, daa: d.daa, drawn, connected_from_directory: connectedFromDirectory });
|
||||
if (p.outbound >= 8 && eightAt == null) { eightAt = +since(); log(`the fresh node holds ${p.outbound} outbound peers at ${since()} s (${connectedFromDirectory} from the directory)`); }
|
||||
if (samples.length % 6 === 0) log(`t=${since()} s fresh: ${p.outbound} outbound, ${p.inbound} inbound, ${d.blocks} blocks, ${drawn} draw line(s), ${connectedFromDirectory} directory connection(s)`);
|
||||
}
|
||||
const last = samples.at(-1);
|
||||
const checks = {
|
||||
listing_lines_on_node_0: listed >= LISTING - 1,
|
||||
fresh_synced: last.blocks > dag0.blocks / 2,
|
||||
fresh_reached_eight_outbound: eightAt != null,
|
||||
fresh_connected_from_directory: last.connected_from_directory >= 7,
|
||||
fresh_held_one_outbound: samples.every(s => s.outbound <= 1),
|
||||
no_directory_line_on_fresh: last.drawn === 0 && last.connected_from_directory === 0,
|
||||
};
|
||||
const good = EXPECT === 'eight'
|
||||
? checks.listing_lines_on_node_0 && checks.fresh_synced && checks.fresh_reached_eight_outbound && checks.fresh_connected_from_directory
|
||||
: checks.fresh_synced && checks.fresh_held_one_outbound && checks.no_directory_line_on_fresh;
|
||||
const needed = EXPECT === 'eight' ? ['listing_lines_on_node_0', 'fresh_synced', 'fresh_reached_eight_outbound', 'fresh_connected_from_directory'] : ['fresh_synced', 'fresh_held_one_outbound', 'no_directory_line_on_fresh'];
|
||||
const fails = needed.filter(k => !checks[k]);
|
||||
const summary = { pass: good, expect: EXPECT, case: CASE, switch: SWITCH, listing: LISTING, joint: JOINT, watch: WATCH, node0: dag0, listing_lines_on_node_0: listed, eight_outbound_at_s: eightAt, last, checks, failed_checks: fails, samples, node: IGNEUMD, miner: CPU_MINER, slot: SLOT, ports: { base: BASE, suffix: SUFFIX } };
|
||||
mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(summary, null, 2));
|
||||
log(`SUMMARY ${good ? 'PASS' : 'FAIL'} (${CASE}): node 0 logged ${listed} listings; the fresh node ${eightAt == null ? `never reached 8 outbound (last ${last.outbound})` : `reached 8 outbound at ${eightAt} s`}, ${last.connected_from_directory} connection(s) from the directory, ${last.drawn} draw line(s), ${last.blocks} blocks${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`);
|
||||
log(`summary: ${OUT}`);
|
||||
await stopAll();
|
||||
process.exit(good ? 0 : 1);
|
||||
73
sim/peer-directory/eclipse.py
Normal file
73
sim/peer-directory/eclipse.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Mission item 12, the eclipse bound of the weight-backed peer directory (docs/analysis/mission/invent.md 7.1, model E).
|
||||
|
||||
A fresh node draws its outbound peers from the directory WITHOUT replacement, each draw proportional to the listing
|
||||
key's weight at the latest checkpoint (the fork's `FinalityManager::draw_peers`). An attacker holding share `a` of the
|
||||
weight lists its addresses under `m` keys of equal weight (each above dust: the dust threshold is what bounds `m`, 100
|
||||
blocks a window on mainnet). The node is eclipsed when every one of its `n` outbound peers is the attacker's.
|
||||
|
||||
Model E's closed form is a^n (with replacement); without replacement and with few attacker keys the odds are lower, and
|
||||
with fewer than n attacker keys an eclipse is impossible. Honest keys: `h` keys with Zipf weights (a few large miners,
|
||||
a long tail), the shape of a public chain's weight table.
|
||||
|
||||
python3 sim/peer-directory/eclipse.py [--starts 1000] [--share 0.34] [--peers 8,16] [--attacker-keys 8,64,1000]
|
||||
[--honest-keys 200] [--seed 7]
|
||||
"""
|
||||
import argparse
|
||||
import random
|
||||
|
||||
|
||||
def draw(pool, n, rng):
|
||||
"""Weighted draw without replacement: pool is a list of (is_attacker, weight)."""
|
||||
pool = list(pool)
|
||||
out = []
|
||||
while len(out) < n and pool:
|
||||
total = sum(w for _, w in pool)
|
||||
r = rng.uniform(0, total)
|
||||
i = 0
|
||||
while i + 1 < len(pool) and r >= pool[i][1]:
|
||||
r -= pool[i][1]
|
||||
i += 1
|
||||
out.append(pool.pop(i)[0])
|
||||
return out
|
||||
|
||||
|
||||
def run(starts, share, peers, attacker_keys, honest_keys, rng):
|
||||
honest_total = 1.0 - share
|
||||
zipf = [1.0 / (k + 1) for k in range(honest_keys)]
|
||||
z = sum(zipf)
|
||||
honest = [(False, honest_total * w / z) for w in zipf]
|
||||
attacker = [(True, share / attacker_keys)] * attacker_keys
|
||||
pool = honest + attacker
|
||||
eclipsed = 0
|
||||
majority = 0
|
||||
for _ in range(starts):
|
||||
got = draw(pool, peers, rng)
|
||||
k = sum(got)
|
||||
if k == len(got):
|
||||
eclipsed += 1
|
||||
if k * 2 > len(got):
|
||||
majority += 1
|
||||
return eclipsed, majority
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--starts", type=int, default=1000)
|
||||
ap.add_argument("--share", type=float, default=0.34)
|
||||
ap.add_argument("--peers", default="8,16")
|
||||
ap.add_argument("--attacker-keys", default="8,64,1000")
|
||||
ap.add_argument("--honest-keys", type=int, default=200)
|
||||
ap.add_argument("--seed", type=int, default=7)
|
||||
a = ap.parse_args()
|
||||
rng = random.Random(a.seed)
|
||||
print(f"starts {a.starts}, attacker share {a.share:.2f} of the weight, {a.honest_keys} honest keys (Zipf), seed {a.seed}")
|
||||
print(f"{'peers':>5} {'attacker keys':>13} {'eclipsed':>9} {'rate':>10} {'a^n (model E)':>14} {'attacker majority':>17}")
|
||||
for n in (int(x) for x in a.peers.split(",")):
|
||||
for m in (int(x) for x in a.attacker_keys.split(",")):
|
||||
e, maj = run(a.starts, a.share, n, m, a.honest_keys, rng)
|
||||
print(f"{n:>5} {m:>13} {e:>9} {e / a.starts:>10.2e} {a.share ** n:>14.2e} {maj / a.starts:>17.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue