igneum/tools/txgen/relay-net.mjs
igneum-labs fe635edcfb EVM transaction relay: design doc 1.4 "Relay", rows 463 and 9 closed, bench-log entry, 3-node relay harness
Fork tx-gossip e242acd0 adds the inventory relay of EVM transactions (protocol version 14) and replaces the
4-second hand-out cooldown with the hold on block-added. Here: the relay paragraph in execution-layer.md 1.4,
the 10.2 table row (hand-out cooldown, now the block-added hold) and 10.3 item 9 updated, the bench-log entry
with the PC 2 suites (igneum-exec 15 of 15, kaspa-p2p-flows 33 of 33), the digest check (unchanged,
9409dedac4bf...) and the 3-node fast-time run (A - B - C, generator on A at 2/s: 240 of 240 included, B 151
and C 105 over two hops, p50 1.5 s, 0 skipped copies, pools equal on all three nodes at every sample), the
result JSON under docs/benchmarks/evm-relay-2026-10-05/, and tools/txgen/relay-net.mjs (the harness: three
nodes in a chain on the fast-time profile, vmine on B and C paid to throwaway keys, txgen on A, inclusion
counted by miner from A's executed chain).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 17:53:07 +00:00

172 lines
11 KiB
JavaScript

#!/usr/bin/env node
// Igneum EVM transaction relay check (5 October 2026, execution-layer ledger item 9). A private 3-node network in a
// chain, A - B - C (B dials A and C; A and C listen and dial nothing, since a harness node that dials accepts no
// inbound connection; a transaction given to A reaches C in two hops, through B), on the 60x fast-time profile with
// proof of work skipped. Only B and C mine (one vmine each, half a block per second, paid to throwaway
// EVM keys made for the run); A mines nothing, so every transaction the generator sends to A can only be included
// by a block B or C built from a pool fed by the relay. The generator is tools/txgen/run.mjs against A's EVM RPC;
// B's block rewards fund it. The report counts inclusion by miner from A's own executed chain
// (igneum_getSegment: every mergeset block with its miner and transaction count, every executed transaction with
// its including block), samples the three pools every 5 s for convergence, and keeps the generator's latency.
//
// IGNEUMD=<igneumd> IGNEUM_MINER=<igneum-miner> tools/lock/with-lock.sh run node tools/txgen/relay-net.mjs
// [--rate 2] [--duration 120] [--wallets 16] [--fund 2] [--out <dir>]
//
// Ports IGNEUM_HARNESS_BASE_PORT (default 29700) and up, data IGNEUM_HARNESS_TMP (default /tmp/igneum-txrelay). The
// live devnet (26610/26611, 26640/26641, 26800, 28640) and other agents' ranges are never touched. No key is printed.
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync, chmodSync, readFileSync, openSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
process.env.IGNEUM_FAST_TIME = '1';
process.env.IGNEUM_HARNESS_BASE_PORT ||= '29700';
process.env.IGNEUM_HARNESS_TMP ||= '/tmp/igneum-txrelay';
const net = await import('../harness/lib/net.mjs');
const { Node, stopAll, log, sleep, BASE_PORT, TMP, IGNEUMD, ROOT } = net;
const args = process.argv.slice(2);
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : dflt; };
const RATE = +opt('--rate', 2);
const DURATION = +opt('--duration', 120);
const WALLETS = +opt('--wallets', 16);
const FUND = +opt('--fund', 2);
const OUT = opt('--out', join(TMP, 'out'));
const MINER = process.env.IGNEUM_MINER || `${ROOT}vendor/igneum-node/target-txgossip/release/igneum-miner`;
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
mkdirSync(OUT, { recursive: true });
const evmPort = (i) => BASE_PORT + i * 10 + 3;
const evmUrl = (i) => `http://127.0.0.1:${evmPort(i)}`;
async function eth(i, method, params = []) {
const r = await fetch(evmUrl(i), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(20000) });
const j = await r.json();
if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`);
return j.result;
}
const ign = (wei) => (Number(BigInt(wei) / 10n ** 14n) / 1e4).toFixed(4);
// Throwaway keys for the two miners (never printed, never reused)
const keyB = generatePrivateKey(), keyC = generatePrivateKey();
const minerB = privateKeyToAccount(keyB), minerC = privateKeyToAccount(keyC);
const funderFile = join(TMP, 'funder.json');
mkdirSync(TMP, { recursive: true });
writeFileSync(funderFile, JSON.stringify({ purpose: 'devnet relay harness, throwaway key of miner B', private_key: keyB, address: minerB.address }));
chmodSync(funderFile, 0o600);
const started = [];
function miner(bin, argv, name) {
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(bin, argv, { stdio: ['ignore', out, out] });
started.push(p);
return p;
}
async function cleanup() {
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
await sleep(1500);
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
await stopAll();
}
process.on('SIGINT', async () => { await cleanup(); process.exit(130); });
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
let exitCode = 1;
try {
const A = await new Node(0, { name: 'A', extraArgs: [`--evm-rpclisten=127.0.0.1:${evmPort(0)}`] }).start();
const C = await new Node(2, { name: 'C', extraArgs: [`--evm-rpclisten=127.0.0.1:${evmPort(2)}`] }).start();
// one --connect per peer: the node's parser takes the flag repeatedly, not a comma list
const B = await new Node(1, { name: 'B', connect: [A.p2p], extraArgs: [`--connect=${C.p2p}`, `--evm-rpclisten=127.0.0.1:${evmPort(1)}`] }).start();
const nodes = [A, B, C];
await sleep(3000);
const peersOf = async (n) => (await net.peers(n)).length;
log(`peers: A ${await peersOf(A)}, B ${await peersOf(B)}, C ${await peersOf(C)} (chain A - B - C)`);
const version = (n) => n.grepLog(/protocol version/).map(l => l.replace(/^.*Registering/, 'Registering')).slice(0, 2);
log(`B log: ${version(B).join(' | ')}`);
const secs = DURATION + 400;
miner(MINER, ['vmine', B.grpc, String(secs), '--label', 'vB', '--share', '0.5', '--bps', '1', '--evm-address', minerB.address.slice(2), '--network', 'devnet'], 'vmine-B');
miner(MINER, ['vmine', C.grpc, String(secs), '--label', 'vC', '--share', '0.5', '--bps', '1', '--evm-address', minerC.address.slice(2), '--network', 'devnet'], 'vmine-C');
log('miners: vmine on B and C at 0.5 blocks/s each; A mines nothing');
// B's rewards reach A's executed state through the relay of blocks; wait for enough to fund the wallets
const wanted = BigInt(Math.ceil(WALLETS * FUND * 1.25 + 2)) * 10n ** 18n;
for (let i = 0; i < 300; i++) {
const bal = BigInt(await eth(0, 'eth_getBalance', [minerB.address, 'latest']).catch(() => '0x0'));
if (bal >= wanted) { log(`miner B holds ${ign(bal)} IGN on A's chain at ${since()} s; funding can start`); break; }
if (i % 10 === 0) log(`waiting for miner B's rewards: ${ign(bal)} IGN of ${ign(wanted)} (t=${since()} s)`);
await sleep(1000);
}
const startBlock = Number(BigInt(await eth(0, 'eth_blockNumber')));
// The generator against A, with the pool sampler beside it
const samples = [];
let sampling = true;
const sampler = (async () => {
while (sampling) {
const row = { t: +since() };
for (const [i, name] of [[0, 'A'], [1, 'B'], [2, 'C']]) {
try { const b = await eth(i, 'igneum_getBudgets'); row[name] = Number(BigInt(b.mempool)); row[`${name}_chain`] = Number(BigInt(b.chainBlocks)); } catch { row[name] = null; }
}
samples.push(row);
await sleep(5000);
}
})();
const summaryFile = join(OUT, 'txgen-summary.json');
const genLog = openSync(join(OUT, 'txgen.log'), 'w');
const gen = spawn(process.execPath, [`${ROOT}tools/txgen/run.mjs`, '--rpc', evmUrl(0), '--rate', String(RATE), '--duration', String(DURATION), '--wallets', String(WALLETS),
'--fund', String(FUND), '--cap', String(WALLETS * FUND * 2 + 10), '--keys', join(TMP, 'wallets.json'), '--funder', funderFile, '--summary', summaryFile, '--stale', '60'],
{ stdio: ['ignore', 'pipe', genLog], cwd: `${ROOT}tools/txgen` });
started.push(gen);
gen.stdout.on('data', (d) => { const s = d.toString(); writeFileSync(join(OUT, 'txgen.log'), s, { flag: 'a' }); for (const l of s.split('\n')) if (/included|funded|funder|fees|summary|error|stopping/i.test(l) && l.trim()) log(`txgen: ${l.trim().slice(0, 160)}`); });
const genExit = await new Promise((r) => gen.on('exit', (code) => r(code)));
log(`generator exited ${genExit} at ${since()} s`);
await sleep(10000); // let the last blocks execute on A
sampling = false; await sampler;
// Inclusion by miner, from A's executed chain
const endBlock = Number(BigInt(await eth(0, 'eth_blockNumber')));
const byMiner = new Map();
const label = (m) => (m.toLowerCase() === minerB.address.toLowerCase() ? 'B' : m.toLowerCase() === minerC.address.toLowerCase() ? 'C' : `other:${m.slice(0, 10)}`);
const bump = (m, k, n = 1) => { const r = byMiner.get(m) || { blocks: 0, blocks_with_txs: 0, txs_carried: 0, executed: 0, skipped: 0 }; r[k] += n; byMiner.set(m, r); };
let executed = 0, skipped = 0, chainBlocks = 0;
for (let n = startBlock + 1; n <= endBlock; n++) {
let seg; try { seg = await eth(0, 'igneum_getSegment', ['0x' + n.toString(16)]); } catch (e) { log(`segment ${n}: ${e.message}`); continue; }
chainBlocks++;
const minerOfBlock = new Map();
for (const m of seg.mergeset || []) {
const who = label(m.miner); minerOfBlock.set(m.hash, who);
bump(who, 'blocks'); if (Number(BigInt(m.txCount)) > 0) { bump(who, 'blocks_with_txs'); bump(who, 'txs_carried', Number(BigInt(m.txCount))); }
}
for (const e of seg.executed || []) { executed++; bump(minerOfBlock.get(e.includingBlock) || 'unknown', 'executed'); }
for (const s of seg.skipped || []) { skipped++; bump(minerOfBlock.get(s.includingBlock) || 'unknown', 'skipped'); }
}
const summary = existsSync(summaryFile) ? JSON.parse(readFileSync(summaryFile, 'utf8')) : null;
const sinks = await Promise.all(nodes.map(async n => { try { return (await net.dagInfo(n)).sink; } catch { return '?'; } }));
const maxPool = (k) => Math.max(0, ...samples.map(s => s[k] ?? 0));
const report = {
tool: 'tools/txgen/relay-net.mjs', node: IGNEUMD, topology: 'A - B - C (B dials A and C); generator on A; vmine on B and C',
params: { rate_per_s: RATE, duration_s: DURATION, wallets: WALLETS, fund_ign: FUND, fast_time: true },
chain_blocks_in_window: chainBlocks, executed, skipped,
by_miner: Object.fromEntries([...byMiner.entries()]),
generator: summary ? { sent: summary.counts?.sent, included: summary.counts?.included, pending_at_end: summary.counts?.pending_at_end, included_per_s: summary.throughput?.included_per_s, latency_ms: summary.latency_ms, blocks_with_content: summary.blocks?.with_content, errors: summary.errors, stop_reason: summary.stop_reason } : null,
pools: { samples, max: { A: maxPool('A'), B: maxPool('B'), C: maxPool('C') } },
sinks_agree: new Set(sinks).size === 1,
wall_s: +since(),
};
writeFileSync(join(OUT, 'relay-report.json'), JSON.stringify(report, null, 2) + '\n');
log('RELAY REPORT');
log(` chain blocks ${chainBlocks}, executed ${executed}, skipped copies ${skipped}, sinks agree ${report.sinks_agree}`);
for (const [m, r] of byMiner) log(` miner ${m}: blocks ${r.blocks}, with transactions ${r.blocks_with_txs}, carried ${r.txs_carried}, executed ${r.executed}, skipped ${r.skipped}`);
if (summary) log(` generator: sent ${summary.counts?.sent}, included ${summary.counts?.included}, pending ${summary.counts?.pending_at_end}, ${summary.throughput?.included_per_s}/s, latency p50 ${summary.latency_ms?.p50} p90 ${summary.latency_ms?.p90} max ${summary.latency_ms?.max} ms`);
log(` pool max: A ${report.pools.max.A}, B ${report.pools.max.B}, C ${report.pools.max.C}`);
log(` report ${join(OUT, 'relay-report.json')}`);
const included = (byMiner.get('B')?.executed || 0) + (byMiner.get('C')?.executed || 0);
exitCode = included > 0 && (byMiner.get('B')?.executed || 0) > 0 && (byMiner.get('C')?.executed || 0) > 0 ? 0 : 1;
} catch (e) {
log(`FAILED: ${e.stack || e.message}`);
} finally {
await cleanup();
}
process.exit(exitCode);