igneum/pool/tools/open-gate.mjs
igneum-labs c83ea0f5d4 Pool finished (mission item 11): pool-0's fee published beside the dev fee, TLS 1.3 on the member port with the authorize binding (spec 9.3, O-9.7 closed), the page rows Q68 to Q73, and the open pool: a share sidechain with no operator (spec 9.12)
Pool-0: welcome carries software_dev_fee_percent beside the pool fee, the page's Fees row and site/miner.html say pool-0 charges the same 1 percent as the solo dev fee; jobs and seeds carry the latency ladder's rung for 0.3.19.

TLS (pool/src/tls.rs): --tls-cert/--tls-key or --tls-self-signed (a P-256 pair under the data dir, the certificate pin printed at start); the binding is the member's BLS signature over this connection's exporter (label EXPORTER-igneum-pool-binding, context the chain id), refused on any other connection; testnet and mainnet refuse the clear without --allow-plain. HiveOS: pools:// and POOL_PIN.

Page rows: node state in words (Q68), one formatter set and luck in MiningPoolStats' convention (Q69), samples and check costs persisted (Q70), payments under the lookup (Q71), hourly history with a sparkline, --alert-webhook, /metrics, /health on the node (Q72), the network's finality state beside "payouts follow blue confirmation, not finality" (Q73).

The open pool (pool/src/sidechain.rs, open.rs, p2p.rs; igneum-pool --open): the member's own node and daemon, no payout key, no balance; every coinbase carries the member's own address, the share chain's parent (IGNS) and the window's split (IGNP); templates re-stamped on a new chain tip without a node call; shares gossiped and checked by every member (structure, the node's seeds, the PoW); heaviest work wins, a fork's loser is stale; the dev fee as one split entry; shares.log and verify-share for the drop proof; the gate harness pool/tools/open-gate.mjs. The node side (the executor's split from pool_split_activation_daa) is on the fork branch pool-finish-node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 57954a43eb)
2026-10-07 14:03:05 +00:00

203 lines
16 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;
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); });
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);
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' };
// 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'];
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 {} }
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] = BigInt(await evm('eth_getBalance', [a, 'latest']));
// 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));
// first shares per member, from the daemons' share lists (their addresses)
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) firstShareMs[s.address] = s.timestamp;
}
const paid = [];
for (const a of ADDR) { const b = BigInt(await evm('eth_getBalance', [a, 'latest'])); if (b > balance0[a] && firstPaidMs[a] == null) firstPaidMs[a] = Date.now(); paid.push(b - balance0[a]); }
const nPaid = paid.filter(x => x > 0n).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
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] = BigInt(await evm('eth_getBalance', [a, 'latest'])) - 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;
// 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 blocks = (await api(0, '/api/blocks?limit=1000')).blocks.filter(b => b.status === 'confirmed');
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,
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,
stale_rate: (() => { const s = opens.reduce((x, o) => x + o.stale, 0), a = opens.reduce((x, o) => x + o.accepted, 0); return a ? +(s / a).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 && 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);