Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit f7694c66d2)
229 lines
18 KiB
JavaScript
229 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
// The open pool's gate (mission item 11; docs/plans/pool.md section 10.5): N members on a private fast-time network,
|
|
// paid by the coinbase rule from a share chain with no operator key; a withheld share earning nothing; a member's
|
|
// dropped share provable from its own log; the first payout inside two minutes of the first share.
|
|
//
|
|
// tools/lock/with-lock.sh run node pool/tools/open-gate.mjs [--members 100] [--daemons 10] [--nodes 4] [--secs 900]
|
|
// [--chain-share-s 0.1] [--window 2160] [--threads 1] [--scratch <dir>] [--node <igneumd>] [--miner <igneum-miner>]
|
|
// [--pool <igneum-pool>] [--genesis-bits 0x1e400000] [--withhold 1] (the last daemon withholds its members' shares)
|
|
// [--dataset-log2 24] the private network's genesis dataset (2^24 words = 64 MiB per process against the
|
|
// devnet's 2^28 = 1 GiB), so 100 CPU members, 10 daemons and 4 nodes fit one 64 GB machine
|
|
//
|
|
// Topology: `--nodes` igneumd on a fast-time private network (ports 30500 and up, network id igneum-devnet-3050, data
|
|
// under the scratch dir), `--daemons` open-pool daemons (each on one of the nodes, each with its own share-chain view,
|
|
// peered in a ring plus two chords), `--members` miners spread over the daemons (one key and one payout address each,
|
|
// `--threads` CPU threads each). The override sets pool_split_activation_daa 0 so every blue block pays by its split.
|
|
//
|
|
// Never touches the live devnet (26610/26611) or other agents' port ranges (27800+, 28500+, 29300+, 29500+, 29700+,
|
|
// 29800+, 29900+, 29950+, 30400+ the pool measure). Output: one line per step and <scratch>/summary.json; every
|
|
// number names its source.
|
|
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, appendFileSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(here, '..', '..');
|
|
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 MEMBERS = +opt('--members', 100);
|
|
const DAEMONS = +opt('--daemons', 10);
|
|
const NODES = +opt('--nodes', 4);
|
|
const SECS = +opt('--secs', 900);
|
|
const CHAIN_SHARE_S = opt('--chain-share-s', '0.1');
|
|
const WINDOW = +opt('--window', 2160);
|
|
const THREADS = +opt('--threads', 1);
|
|
const WITHHOLD = +opt('--withhold', 1);
|
|
const SCRATCH = opt('--scratch', process.env.SCRATCH || `/tmp/igneum-pool-open-gate`);
|
|
const NODE = opt('--node', `${ROOT}/vendor/igneum-node-pf/target/release/igneumd`);
|
|
const MINER = opt('--miner', `${ROOT}/vendor/igneum-node-pf/target/release/igneum-miner`);
|
|
const POOL = opt('--pool', `${ROOT}/pool/target/release/igneum-pool`);
|
|
const GENESIS_BITS = Number(opt('--genesis-bits', '0x1e400000'));
|
|
const DATASET_LOG2 = +opt('--dataset-log2', 24);
|
|
const BASE = 30500, SUFFIX = 3050, DAEMON_BASE = 30600, P2P_BASE = 30700, HTTP_BASE = 30800;
|
|
// the share chain's first target: the network's genesis block target (Kaspa compact bits to a 64-bit target) eight times easier
|
|
const target64 = (bits) => { const exp = BigInt(bits >>> 24), mant = BigInt(bits & 0xffffff); const t256 = exp <= 3n ? mant >> (8n * (3n - exp)) : mant << (8n * (exp - 3n)); return t256 >> 192n; };
|
|
const CHAIN_GENESIS_TARGET = (target64(GENESIS_BITS) << 3n).toString(16).padStart(16, '0');
|
|
const FAST = `${ROOT}/infra/fast-time/override-60x.json`;
|
|
const log = (...a) => { const l = new Date().toISOString().slice(11, 23) + ' ' + a.join(' '); console.log(l); try { appendFileSync(join(SCRATCH, 'gate.log'), l + '\n'); } catch {} };
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
for (const b of [NODE, MINER, POOL]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
|
rmSync(SCRATCH, { recursive: true, force: true }); mkdirSync(SCRATCH, { recursive: true });
|
|
const started = [];
|
|
const stopAll = () => { for (const p of started.splice(0).reverse()) { try { p.kill('SIGINT'); } catch {} } };
|
|
process.on('SIGINT', () => { stopAll(); process.exit(130); });
|
|
process.on('SIGTERM', () => { stopAll(); process.exit(143); });
|
|
// a harness fault never leaves a network behind (the first smoke run did, and the next run read the old daemons)
|
|
process.on('uncaughtException', (e) => { console.error(e); stopAll(); process.exit(1); });
|
|
process.on('unhandledRejection', (e) => { console.error(e); stopAll(); process.exit(1); });
|
|
const summary = { started: new Date().toISOString(), members: MEMBERS, daemons: DAEMONS, nodes: NODES, secs: SECS, chain_share_s: +CHAIN_SHARE_S, window: WINDOW, threads: THREADS, genesis_bits: '0x' + GENESIS_BITS.toString(16), dataset_log2: DATASET_LOG2, withhold: WITHHOLD, steps: {} };
|
|
const save = () => writeFileSync(join(SCRATCH, 'summary.json'), JSON.stringify(summary, null, 2));
|
|
|
|
// the override: the 60x profile with real proof of work at a CPU difficulty and the split switch at 0
|
|
const override = join(SCRATCH, 'override.json');
|
|
let text = readFileSync(FAST, 'utf8')
|
|
.replace(/"skip_proof_of_work":\s*(true|false)/, '"skip_proof_of_work": false')
|
|
.replace(/"genesis_bits":\s*\d+/, `"genesis_bits": ${GENESIS_BITS}`);
|
|
if (!/"skip_proof_of_work": false/.test(text) || !text.includes(`"genesis_bits": ${GENESIS_BITS}`)) throw new Error('override edit failed');
|
|
text = text.replace(/"pow_genesis_dataset_log2":\s*\d+/, `"pow_genesis_dataset_log2": ${DATASET_LOG2}`);
|
|
if (!text.includes(`"pow_genesis_dataset_log2": ${DATASET_LOG2}`)) throw new Error('dataset edit failed');
|
|
if (/"pool_split_activation_daa"/.test(text)) text = text.replace(/"pool_split_activation_daa":\s*\d+/, '"pool_split_activation_daa": 0');
|
|
else text = text.replace(/\n}\s*$/, ',\n "pool_split_activation_daa": 0\n}\n');
|
|
if (!text.includes('"pool_split_activation_daa": 0')) throw new Error('split switch edit failed');
|
|
writeFileSync(override, text);
|
|
|
|
function run(name, bin, a, env = {}, nice = 0) {
|
|
const out = openSync(join(SCRATCH, `${name}.log`), 'a');
|
|
const p = nice ? spawn('nice', ['-n', String(nice), bin, ...a], { stdio: ['ignore', out, out], env: { ...process.env, ...env } }) : spawn(bin, a, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
|
|
p.on('exit', (code) => { p.exitCode2 = code; });
|
|
started.push(p);
|
|
appendFileSync(join(SCRATCH, 'pids'), `${p.pid} ${name}\n`);
|
|
return p;
|
|
}
|
|
const logOf = (name) => { try { return readFileSync(join(SCRATCH, `${name}.log`), 'utf8'); } catch { return ''; } };
|
|
async function api(d, path) { const r = await fetch(`http://127.0.0.1:${HTTP_BASE + d}${path}`); return r.json(); }
|
|
let rpcId = 0;
|
|
async function evm(method, params = [], n = 0) {
|
|
const r = await fetch(`http://127.0.0.1:${BASE + n * 4 + 3}`, { 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(j.error.message || JSON.stringify(j.error)); return j.result;
|
|
}
|
|
const FT = { IGNEUM_POW_DAY_MS: '1440000' };
|
|
// a balance before the execution layer has executed a chain block reads as 0 (the follower answers an error until then)
|
|
const bal = async (a) => { try { return BigInt(await evm('eth_getBalance', [a, 'latest'])); } catch (e) { if (/no chain block/i.test(e.message)) return 0n; throw e; } };
|
|
|
|
// 1. the nodes, a ring
|
|
const nodePorts = (n) => ({ grpc: BASE + n * 4, p2p: BASE + n * 4 + 1, json: BASE + n * 4 + 2, evm: BASE + n * 4 + 3 });
|
|
for (let n = 0; n < NODES; n++) {
|
|
const p = nodePorts(n);
|
|
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
|
|
`--appdir=${join(SCRATCH, 'node' + n)}`, `--rpclisten=127.0.0.1:${p.grpc}`, `--rpclisten-json=127.0.0.1:${p.json}`, `--evm-rpclisten=127.0.0.1:${p.evm}`, `--listen=127.0.0.1:${p.p2p}`,
|
|
`--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
|
if (n > 0) a.push(`--connect=127.0.0.1:${nodePorts(n - 1).p2p}`); else a.push('--outpeers=0');
|
|
run(`node${n}`, NODE, a, FT);
|
|
}
|
|
for (let i = 0; i < 30; i++) { await sleep(1000); if (spawnSync(MINER, ['watch', '1', `grpc://127.0.0.1:${BASE}`], { timeout: 15000 }).status === 0) break; }
|
|
log(`${NODES} nodes up`);
|
|
|
|
// 2. the daemons: each on a node, no payout key, the share chain peered in a ring with two chords
|
|
const daemonNode = (d) => d % NODES;
|
|
for (let d = 0; d < DAEMONS; d++) {
|
|
const n = nodePorts(daemonNode(d));
|
|
const peers = new Set([(d + 1) % DAEMONS, (d + DAEMONS - 1) % DAEMONS, (d + 3) % DAEMONS, (d + 7) % DAEMONS]);
|
|
peers.delete(d);
|
|
const a = ['--open', '--node', `grpc://127.0.0.1:${n.grpc}`, '--evm-rpc', `http://127.0.0.1:${n.evm}`, '--listen', `127.0.0.1:${DAEMON_BASE + d}`, '--http', `127.0.0.1:${HTTP_BASE + d}`,
|
|
'--p2p-listen', `127.0.0.1:${P2P_BASE + d}`, '--data-dir', join(SCRATCH, `daemon${d}`), '--network', 'devnet', '--chain-share-s', CHAIN_SHARE_S, '--window-shares', String(WINDOW),
|
|
'--name', `open daemon ${d}`, '--public-url', `127.0.0.1:${DAEMON_BASE + d}`, '--share-interval-s', '10', '--verify-threads', '2', '--snapshot-interval-s', '15', '--chain-genesis-target64', CHAIN_GENESIS_TARGET];
|
|
for (const p of peers) a.push('--peer', `127.0.0.1:${P2P_BASE + p}`);
|
|
if (WITHHOLD && d === DAEMONS - 1) a.push('--withhold-shares');
|
|
run(`daemon${d}`, POOL, a, FT);
|
|
}
|
|
for (let d = 0; d < DAEMONS; d++) for (let i = 0; i < 60; i++) { await sleep(500); try { const h = await fetch(`http://127.0.0.1:${HTTP_BASE + d}/api/stats`); if (h.ok) break; } catch {} }
|
|
for (let d = 0; d < DAEMONS; d++) if (/POOL NOT STARTED/.test(logOf(`daemon${d}`))) { log(`daemon ${d} did not start: ${logOf(`daemon${d}`).trim().split('\n').pop()}`); stopAll(); process.exit(2); }
|
|
log(`${DAEMONS} daemons up${WITHHOLD ? ` (daemon ${DAEMONS - 1} withholds its shares)` : ''}`);
|
|
|
|
// 3. the members: one key and one payout address each, spread over the daemons
|
|
const ADDR = Array.from({ length: MEMBERS }, (_, i) => '0x' + (i + 1).toString(16).padStart(4, '0').repeat(10));
|
|
const memberDaemon = (m) => m % DAEMONS;
|
|
for (let m = 0; m < MEMBERS; m++) {
|
|
const d = memberDaemon(m);
|
|
const n = nodePorts(daemonNode(d));
|
|
run(`member${m}`, MINER, ['mine', `grpc://127.0.0.1:${n.grpc}`, String(THREADS), String(SECS + 300), `open-m${m}`, '--pool', `127.0.0.1:${DAEMON_BASE + d}`, '--evm-address', ADDR[m], '--worker-name', `m${m}`, '--status-secs', '60', '--network', 'devnet'], FT, 15);
|
|
if (m % 10 === 9) await sleep(200);
|
|
}
|
|
log(`${MEMBERS} members started`);
|
|
const t0 = Date.now();
|
|
const firstShareMs = {}, firstPaidMs = {};
|
|
const samples = [];
|
|
const balance0 = {};
|
|
for (const a of ADDR) balance0[a] = (await bal(a));
|
|
// the first-payout watcher: every 5 s, the first share of every address (header time, from the daemons' chains) and
|
|
// the first chain credit (eth_getBalance), so the latency is read to 5 s
|
|
let watching = true;
|
|
const watcher = (async () => {
|
|
while (watching) {
|
|
await sleep(5000);
|
|
try {
|
|
for (let d = 0; d < DAEMONS; d++) {
|
|
const sh = await api(d, `/api/open/shares?from=0&limit=2048`);
|
|
for (const s of sh.shares) if (firstShareMs[s.address] == null || s.timestamp < firstShareMs[s.address]) firstShareMs[s.address] = s.timestamp;
|
|
}
|
|
for (const a of ADDR) if (firstPaidMs[a] == null) { const b = await bal(a); if (b > balance0[a]) firstPaidMs[a] = Date.now(); }
|
|
} catch (e) { log(`watcher: ${e.message}`); }
|
|
}
|
|
})();
|
|
|
|
// 4. the run: every 30 s, every daemon's chain and the chain-side balances
|
|
while (Date.now() - t0 < SECS * 1000) {
|
|
await sleep(30000);
|
|
try {
|
|
const opens = await Promise.all(Array.from({ length: DAEMONS }, (_, d) => api(d, '/api/open').then(j => j.open)));
|
|
const stats0 = await api(0, '/api/stats');
|
|
const heights = opens.map(o => o.height);
|
|
const tips = new Set(opens.map(o => o.tip));
|
|
const nPaid = ADDR.filter(a => firstPaidMs[a] != null).length;
|
|
samples.push({ t: Math.round((Date.now() - t0) / 1000), heights, tips: tips.size, accepted: opens.map(o => o.accepted), stale: opens.map(o => o.stale), reorgs: opens.map(o => o.reorgs), rejected: opens.map(o => o.rejected), blocks: opens.map(o => o.blocks_found), paid_members: nPaid, daa: stats0.network.daa_score, net_hashrate: stats0.network.hashrate });
|
|
log(`t=${Math.round((Date.now() - t0) / 1000)}s heights=${Math.min(...heights)}..${Math.max(...heights)} tips=${tips.size} stale=${opens.reduce((s, o) => s + o.stale, 0)} reorgs=${opens.reduce((s, o) => s + o.reorgs, 0)} rejected=${opens.reduce((s, o) => s + o.rejected, 0)} blocks=${opens.reduce((s, o) => s + o.blocks_found, 0)} paid_members=${nPaid}/${MEMBERS} with_shares=${Object.keys(firstShareMs).length} daa=${stats0.network.daa_score} target=${opens[0].target64}`);
|
|
} catch (e) { log(`sample failed: ${e.message}`); }
|
|
}
|
|
|
|
// 5. the verdicts
|
|
watching = false; await watcher;
|
|
const opens = await Promise.all(Array.from({ length: DAEMONS }, (_, d) => api(d, '/api/open').then(j => j.open)));
|
|
const credits = {};
|
|
for (const a of ADDR) credits[a] = (await bal(a)) - balance0[a];
|
|
const withheld = WITHHOLD ? ADDR.filter((_, m) => memberDaemon(m) === DAEMONS - 1) : [];
|
|
const honest = ADDR.filter(a => !withheld.includes(a));
|
|
const paidHonest = honest.filter(a => credits[a] > 0n);
|
|
const withShares = honest.filter(a => firstShareMs[a] != null);
|
|
// the withheld daemon's shares are in nobody else's chain; its members earn nothing from others' blocks (its own
|
|
// blocks, if any, pay its own window: those credits are expected and counted apart)
|
|
const withheldSeen = [];
|
|
for (let d = 0; d < DAEMONS - (WITHHOLD ? 1 : 0); d++) { const sh = await api(d, '/api/open/shares?from=0&limit=2048'); for (const s of sh.shares) if (withheld.includes(s.address)) withheldSeen.push(s.hash); }
|
|
const withheldBlocks = WITHHOLD ? opens[DAEMONS - 1].blocks_found : 0;
|
|
// an honest daemon's block never pays a withheld address (its split is the chain the withholder kept its shares out of)
|
|
let honestBlocks = 0, withheldPaidByHonest = 0;
|
|
for (let d = 0; d < DAEMONS - (WITHHOLD ? 1 : 0); d++) { const bl = await api(d, '/api/blocks?limit=1000'); for (const b of bl.blocks) { honestBlocks++; if (b.payees.some(p => withheld.includes(p.address))) withheldPaidByHonest++; } }
|
|
// first payout latency: from the member's first share (header time) to the first chain credit
|
|
const latencies = honest.filter(a => firstShareMs[a] && firstPaidMs[a]).map(a => (firstPaidMs[a] - firstShareMs[a]) / 1000);
|
|
latencies.sort((x, y) => x - y);
|
|
const pct = (q) => latencies.length ? +latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * q))].toFixed(1) : null;
|
|
// the drop proof: a share from member 0's own log, re-verified by the binary, against the next block any daemon found
|
|
const shareLine = (logOf('daemon0').match(/OPEN SHARE [0-9a-f]+ height \d+/g) || [])[0] || null;
|
|
let proof = null;
|
|
try {
|
|
const lines = readFileSync(join(SCRATCH, 'daemon0', 'shares.log'), 'utf8').trim().split('\n').filter(Boolean);
|
|
const first = lines[0];
|
|
const share = JSON.parse(first.replace(/^SHARE /, ''));
|
|
const all = (await api(0, '/api/blocks?limit=1000')).blocks.filter(b => b.status !== 'orphan');
|
|
const blocks = all.some(b => b.status === 'confirmed') ? all.filter(b => b.status === 'confirmed') : all;
|
|
const after = blocks.filter(b => b.ts_ms >= share.timestamp).sort((x, y) => x.ts_ms - y.ts_ms)[0];
|
|
let blockRaw = null;
|
|
if (after) { const s = await api(0, `/api/open/share/${after.hash}`); if (s.ok) blockRaw = JSON.stringify(s.share.raw); }
|
|
const vargs = ['verify-share', first]; if (blockRaw) { writeFileSync(join(SCRATCH, 'proof-block.json'), blockRaw); vargs.push('--block', join(SCRATCH, 'proof-block.json')); }
|
|
const v = spawnSync(POOL, vargs, { encoding: 'utf8', timeout: 120000, env: { ...process.env, ...FT } });
|
|
proof = { share: share.hash, height: share.height, block: after?.hash || null, exit: v.status, out: (v.stdout || '').trim().split('\n'), source: 'igneum-pool verify-share on daemon0/shares.log line 1 against the first confirmed block after it (its raw block from /api/open/share)' };
|
|
} catch (e) { proof = { error: e.message }; }
|
|
summary.steps.run = { samples, source: 'GET /api/open and /api/open/shares on every daemon every 30 s; eth_getBalance on node 0' };
|
|
summary.steps.verdict = {
|
|
honest_members: honest.length, honest_with_a_share: withShares.length, honest_paid_by_coinbase: paidHonest.length,
|
|
withheld_members: withheld.length, withheld_shares_seen_elsewhere: withheldSeen.length, withheld_credits_ign: withheld.map(a => Number(credits[a]) / 1e18), withheld_daemon_blocks: withheldBlocks,
|
|
honest_blocks: honestBlocks, honest_blocks_paying_a_withheld_address: withheldPaidByHonest,
|
|
first_payout_after_first_share_s: { n: latencies.length, p50: pct(0.5), p90: pct(0.9), max: latencies.length ? +latencies[latencies.length - 1].toFixed(1) : null, under_120_s: latencies.filter(x => x <= 120).length },
|
|
chains: opens.map((o, d) => ({ daemon: d, height: o.height, tip: o.tip, accepted: o.accepted, stale: o.stale, reorgs: o.reorgs, rejected: o.rejected, blocks: o.blocks_found, last_reject: o.last_reject })),
|
|
tips_agree: new Set(opens.slice(0, DAEMONS - (WITHHOLD ? 1 : 0)).map(o => o.tip)).size === 1,
|
|
// the honest daemons' fork rate (the withholder's view diverges by design: its own shares are in its chain alone)
|
|
stale_rate: (() => { const h = opens.slice(0, DAEMONS - (WITHHOLD ? 1 : 0)); const s = h.reduce((x, o) => x + o.stale, 0), a = h.reduce((x, o) => x + o.accepted, 0); return a ? +(s / a).toFixed(4) : null; })(),
|
|
withholder_stale_rate: WITHHOLD ? +(opens[DAEMONS - 1].stale / Math.max(1, opens[DAEMONS - 1].accepted)).toFixed(4) : null,
|
|
total_credited_ign: Number(ADDR.reduce((s, a) => s + credits[a], 0n)) / 1e18,
|
|
drop_proof: proof,
|
|
first_share_line: shareLine,
|
|
pass: paidHonest.length === honest.length && withheldSeen.length === 0 && withheldPaidByHonest === 0 && latencies.length > 0 && pct(0.9) != null && pct(0.9) <= 120 && proof && proof.exit === 0,
|
|
source: 'eth_getBalance before and after on node 0 (the coinbase rule, pool_split_activation_daa 0); the daemons\' /api/open; daemon0/shares.log; igneum-pool verify-share',
|
|
};
|
|
save();
|
|
log(`VERDICT ${summary.steps.verdict.pass ? 'PASS' : 'FAIL'}: ${JSON.stringify({ ...summary.steps.verdict, chains: undefined, drop_proof: proof && { exit: proof.exit, out: proof.out } })}`);
|
|
stopAll();
|
|
await sleep(2000);
|
|
log(`done; summary at ${join(SCRATCH, 'summary.json')}`);
|
|
process.exit(summary.steps.verdict.pass ? 0 : 1);
|