#!/usr/bin/env node
// Pool v0 measured on a private fast-time network (docs/plans/pool.md): one igneumd on ports 30400 and up (network
// id igneum-devnet-3040, data under the scratch directory), the pool, and three CPU miners of different identities
// and thread counts pointed at it. Records shares per minute per miner, the share check cost, found blocks, orphans,
// the PPLNS credits (dry run, then a real local payout on the same chain), and the API under 100 requests per second.
// Never touches the live devnet (26610/26611) or other agents' port ranges (27800+, 28500+, 29300+, 29500+, 29700+,
// 29800+, 29900+, 29950+).
//
// tools/lock/with-lock.sh run node pool/tools/measure.mjs [--secs 600] [--payout-secs 240] [--rps 100] [--scratch
]
// [--node ] [--miner ] [--pool ] [--threads 4,2,2] [--genesis-bits 0x1e400000]
// [--carrier 8] a solo CPU miner of that many threads as "the rest of the network", so the pool holds a minority
// of the hashrate, vardiff has shares to set, and the pool's blocks compete (orphans)
//
// Output: one line per step and a JSON summary at /summary.json. Every number in the summary names its source.
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } 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, '..', '..'); // the repository worktree
const MAIN = '/Users/joshm/Projects/igneum'; // the main checkout (the 0.3.6 node binary, the fast-time profile)
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 SECS = +opt('--secs', 600);
const PAYOUT_SECS = +opt('--payout-secs', 240);
const RPS = +opt('--rps', 100);
const SCRATCH = opt('--scratch', process.env.SCRATCH || `/tmp/igneum-pool-measure`);
const NODE = opt('--node', `${MAIN}/vendor/igneum-node-036/target-integration/release/igneumd`);
const MINER = opt('--miner', `${MAIN}/vendor/igneum-node-pool/target/release/igneum-miner`);
const POOL = opt('--pool', `${ROOT}/pool/target/release/igneum-pool`);
const THREADS = opt('--threads', '4,2,2').split(',').map(Number);
const CARRIER = +opt('--carrier', 0);
const GENESIS_BITS = Number(opt('--genesis-bits', '0x1e400000'));
const BASE = 30400, SUFFIX = 3040, POOL_PORT = 30463, HTTP_PORT = 30480;
const FAST = `${MAIN}/infra/fast-time/override-60x.json`;
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 [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(), node: NODE, miner: MINER, pool: POOL, threads: THREADS, carrier_threads: CARRIER, genesis_bits: '0x' + GENESIS_BITS.toString(16), secs: SECS, 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; edited as text (u64 "never" sentinels)
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');
writeFileSync(override, text);
function run(name, bin, a, env = {}) {
const out = openSync(join(SCRATCH, `${name}.log`), 'a');
const p = spawn(bin, a, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
p.on('exit', (code) => { p.exitCode2 = code; });
started.push(p);
log(`${name} pid ${p.pid}: ${bin.split('/').pop()} ${a.join(' ').slice(0, 160)}`);
return p;
}
const logOf = (name) => { try { return readFileSync(join(SCRATCH, `${name}.log`), 'utf8'); } catch { return ''; } };
async function api(path) { const r = await fetch(`http://127.0.0.1:${HTTP_PORT}${path}`); return r.json(); }
let rpcId = 0;
async function evm(method, params = []) {
const r = await fetch(`http://127.0.0.1:${BASE + 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;
}
// 1. the node
const nodeDir = join(SCRATCH, 'node');
run('node', NODE, ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--outpeers=0',
`--appdir=${nodeDir}`, `--rpclisten=127.0.0.1:${BASE}`, `--rpclisten-json=127.0.0.1:${BASE + 2}`, `--evm-rpclisten=127.0.0.1:${BASE + 3}`, `--listen=127.0.0.1:${BASE + 1}`,
`--override-params-file=${override}`, '--loglevel=info', '--yes'], { IGNEUM_POW_DAY_MS: '1440000' });
// `watch 1` takes one sample, sleeps 10 s and prints its summary: about 11 s, so the timeout is 15 s (an 8 s timeout
// killed every attempt and the first run waited 60 x 9 s before the pool started, 5 October 2026)
for (let i = 0; i < 20; i++) { await sleep(1000); if (spawnSync(MINER, ['watch', '1', `grpc://127.0.0.1:${BASE}`], { timeout: 15000 }).status === 0) break; }
log('node answers');
if (CARRIER > 0) run('carrier', MINER, ['mine', `grpc://127.0.0.1:${BASE}`, String(CARRIER), String(SECS + PAYOUT_SECS + 180), 'carrier', '--engine', 'igneum-pow', '--evm-address', '0x' + '44'.repeat(20), '--status-secs', '30', '--dev-fee', '0'], { IGNEUM_POW_DAY_MS: '1440000' });
// the three miners' payout addresses: fixed test values, no keys behind them (what the pool pays is checked on chain)
const ADDR = ['0x' + '11'.repeat(20), '0x' + '22'.repeat(20), '0x' + '33'.repeat(20)];
// 2. the pool, dry run first
const poolDir = join(SCRATCH, 'pool-data');
const poolArgs = (extra) => ['--node', `grpc://127.0.0.1:${BASE}`, '--evm-rpc', `http://127.0.0.1:${BASE + 3}`, '--listen', `127.0.0.1:${POOL_PORT}`, '--http', `127.0.0.1:${HTTP_PORT}`,
'--data-dir', poolDir, '--network', 'devnet', '--fee-percent', '1', '--pplns-window', '2', '--payout-interval-s', '30', '--name', 'Igneum pool (measure)', '--public-url', `127.0.0.1:${POOL_PORT}`, ...extra];
let pool = run('pool-dry', POOL, poolArgs(['--dry-run', '--min-payout', '0.5']), { IGNEUM_POW_DAY_MS: '1440000' });
for (let i = 0; i < 30; i++) { await sleep(1000); try { if ((await api('/health')).ok) break; } catch {} }
const first = await api('/api/stats');
log(`pool up: address ${first.pool.address}, chain ${first.network.chain_id}`);
summary.pool_address = first.pool.address;
// 3. three miners of different identities, thread counts and payout addresses
const miners = THREADS.map((t, i) => run(`miner${i + 1}`, MINER, ['mine', `grpc://127.0.0.1:${BASE}`, String(t), String(SECS + PAYOUT_SECS + 120), `pool-m${i + 1}`,
'--pool', `127.0.0.1:${POOL_PORT}`, '--evm-address', ADDR[i], '--worker-name', `rig${i + 1}`, '--status-secs', '30', '--network', 'devnet'], { IGNEUM_POW_DAY_MS: '1440000' }));
const t0 = Date.now();
let samples = [];
while (Date.now() - t0 < SECS * 1000) {
await sleep(30000);
try {
const s = await api('/api/stats');
const ms = await Promise.all(ADDR.map(a => api(`/api/miners/${a}`)));
const pm = s.pool.blocks_total, nb = s.network.block_count;
samples.push({ t: (Date.now() - t0) / 1000, pool_block_share: nb ? +(pm / nb).toFixed(3) : null, accepted: s.pool.shares.accepted, stale: s.pool.shares.stale, rejected: s.pool.shares.rejected, blocks: s.pool.blocks_total, confirmed: s.pool.blocks_confirmed_total, orphans: s.pool.blocks_orphaned_total, hashrate: s.pool.hashrate, check: s.pool.share_check_ms, per_miner: ms.map(m => ({ accepted: m.shares.accepted, hashrate: m.hashrate, shift: m.workers[0]?.shift })) , net_diff: s.network.difficulty, daa: s.network.daa_score });
log(`t=${Math.round((Date.now() - t0) / 1000)}s accepted=${s.pool.shares.accepted} stale=${s.pool.shares.stale} rejected=${s.pool.shares.rejected} blocks=${s.pool.blocks_total}/${s.pool.blocks_confirmed_total}c/${s.pool.blocks_orphaned_total}o hashrate=${Math.round(s.pool.hashrate)} H/s check p50=${s.pool.share_check_ms.p50} ms miners=${ms.map(m => m.shares.accepted + '@s' + (m.workers[0]?.shift ?? '?')).join(' ')} diff=${Math.round(s.network.difficulty)} daa=${s.network.daa_score}`);
} catch (e) { log(`sample failed: ${e.message}`); }
for (const [i, m] of miners.entries()) if (m.exitCode2 != null) log(`miner${i + 1} EXITED ${m.exitCode2}`);
}
// the dry-run phase, read back
const stats = await api('/api/stats');
const blocks = await api('/api/blocks?limit=1000');
const payments = await api('/api/payments?limit=1000');
const minersApi = await Promise.all(ADDR.map(a => api(`/api/miners/${a}`)));
const secsRun = (Date.now() - t0) / 1000;
summary.steps.dry_run = {
secs: Math.round(secsRun),
shares_accepted: stats.pool.shares.accepted, shares_stale: stats.pool.shares.stale, shares_rejected: stats.pool.shares.rejected,
shares_per_minute_pool: +(stats.pool.shares.accepted / secsRun * 60).toFixed(2),
per_miner: minersApi.map((m, i) => ({ worker: `rig${i + 1}`, threads: THREADS[i], accepted: m.shares.accepted, stale: m.shares.stale, rejected: m.shares.rejected, shares_per_minute: +(m.shares.accepted / secsRun * 60).toFixed(2), hashrate_10m_hs: m.hashrate, final_shift: m.workers[0]?.shift, blocks: m.blocks, balance_ign: m.balance_ign })),
share_check_ms: stats.pool.share_check_ms,
pool_hashrate_hs: stats.pool.hashrate, network_hashrate_hs: stats.network.hashrate, network_difficulty: stats.network.difficulty,
blocks_found: stats.pool.blocks_total, blocks_confirmed: stats.pool.blocks_confirmed_total, blocks_orphaned: stats.pool.blocks_orphaned_total,
network_block_count: stats.network.block_count, pool_share_of_blocks: stats.network.block_count ? +(stats.pool.blocks_total / stats.network.block_count).toFixed(3) : null,
carrier_status: CARRIER ? ((logOf('carrier').match(/STATUS .*|miner 'carrier'.*found=\d+.*/g) || []).slice(-1)[0] || null) : null,
orphan_rate: stats.pool.blocks_total ? +(stats.pool.blocks_orphaned_total / stats.pool.blocks_total).toFixed(4) : null,
pending_balance_ign: stats.pool.pending_balance_ign, pool_fee_total_ign: stats.pool.pool_fee_total_ign,
dry_run_payments: payments.payments.filter(p => p.dry_run).length,
first_confirmed_block: blocks.blocks.filter(b => b.status === 'confirmed').slice(-1)[0] || null,
samples,
source: 'GET /api/stats, /api/blocks, /api/miners/, /api/payments on the measure pool; miners rig1..3 STATUS lines in /minerN.log',
};
save();
log(`dry run done: ${JSON.stringify({ ...summary.steps.dry_run, samples: undefined, first_confirmed_block: undefined })}`);
// 4. the real payout: the same pool state, without --dry-run; the pool's address was paid 80% of every blue block it
// found by the execution layer, so its balance covers the PPLNS credits
pool.kill('SIGINT'); await sleep(3000);
const poolBalanceBefore = BigInt(await evm('eth_getBalance', [first.pool.address, 'latest']));
const minerBalancesBefore = await Promise.all(ADDR.map(a => evm('eth_getBalance', [a, 'latest']).then(BigInt)));
log(`pool EVM balance before payout: ${Number(poolBalanceBefore) / 1e18} IGN; miners ${minerBalancesBefore.map(b => Number(b) / 1e18).join(' ')}`);
pool = run('pool-real', POOL, poolArgs(['--min-payout', '0.1']), { IGNEUM_POW_DAY_MS: '1440000' });
for (let i = 0; i < 30; i++) { await sleep(1000); try { if ((await api('/health')).ok) break; } catch {} }
const t1 = Date.now();
while (Date.now() - t1 < PAYOUT_SECS * 1000) {
await sleep(15000);
try { const p = await api('/api/payments?limit=100'); const real = p.payments.filter(x => !x.dry_run); log(`payments: ${real.length} real (${real.filter(x => x.status === 'confirmed').length} confirmed, ${real.filter(x => x.status === 'sent').length} sent, ${real.filter(x => x.status === 'failed').length} failed)`); } catch (e) { log(`payments read failed: ${e.message}`); }
}
const pay = await api('/api/payments?limit=1000');
const real = pay.payments.filter(x => !x.dry_run);
const minerBalancesAfter = await Promise.all(ADDR.map(a => evm('eth_getBalance', [a, 'latest']).then(BigInt)));
const poolBalanceAfter = BigInt(await evm('eth_getBalance', [first.pool.address, 'latest']));
const stats2 = await api('/api/stats');
summary.steps.real_payout = {
secs: PAYOUT_SECS,
pool_balance_before_ign: Number(poolBalanceBefore) / 1e18, pool_balance_after_ign: Number(poolBalanceAfter) / 1e18,
payments: real.map(p => ({ address: p.address, amount_ign: p.amount_ign, tx: p.tx_hash, status: p.status })),
confirmed: real.filter(x => x.status === 'confirmed').length, sent: real.filter(x => x.status === 'sent').length, failed: real.filter(x => x.status === 'failed').length,
miner_balances_ign: ADDR.map((a, i) => ({ address: a, before: Number(minerBalancesBefore[i]) / 1e18, after: Number(minerBalancesAfter[i]) / 1e18, received: Number(minerBalancesAfter[i] - minerBalancesBefore[i]) / 1e18 })),
paid_total_ign: stats2.pool.paid_total_ign, pending_after_ign: stats2.pool.pending_balance_ign,
source: 'eth_getBalance on the private node before and after; GET /api/payments (status from eth_getTransactionReceipt)',
};
save();
log(`real payout: ${JSON.stringify({ ...summary.steps.real_payout, payments: summary.steps.real_payout.payments.length })}`);
// 5. the API under load: RPS requests per second for 30 s across the four endpoints, latency percentiles
const paths = ['/api/stats', '/api/blocks?limit=50', `/api/miners/${ADDR[0]}`, '/api/payments?limit=50'];
const lat = []; let errors = 0; let sent = 0;
const tl = Date.now(); const interval = 1000 / RPS;
const inflight = new Set();
while (Date.now() - tl < 30000) {
const target = tl + sent * interval;
const wait = target - Date.now(); if (wait > 0) await sleep(wait);
const p = paths[sent % paths.length]; sent++;
const s = performance.now();
const f = fetch(`http://127.0.0.1:${HTTP_PORT}${p}`).then(r => r.json()).then(j => { if (!j.ok) errors++; lat.push(performance.now() - s); }).catch(() => { errors++; }).finally(() => inflight.delete(f));
inflight.add(f);
}
await Promise.all([...inflight]);
lat.sort((a, b) => a - b);
const pct = (q) => +(lat[Math.min(lat.length - 1, Math.floor(lat.length * q))] || 0).toFixed(2);
summary.steps.api_load = { rps_target: RPS, secs: 30, sent, ok: lat.length, errors, achieved_rps: +(sent / 30).toFixed(1), latency_ms: { p50: pct(0.5), p90: pct(0.9), p99: pct(0.99), max: +(lat[lat.length - 1] || 0).toFixed(2) }, source: 'this script, fetch() from Node 22 on the same machine against the measure pool' };
save();
log(`api load: ${JSON.stringify(summary.steps.api_load)}`);
// 6. the miners' own view
summary.miners_summary = THREADS.map((_, i) => (logOf(`miner${i + 1}`).match(/MINER SUMMARY.*/g) || []).slice(-1)[0] || (logOf(`miner${i + 1}`).match(/STATUS pool=.*/g) || []).slice(-1)[0] || null);
summary.pool_status_lines = (logOf('pool-dry').match(/STATUS .*/g) || []).slice(-3);
summary.finished = new Date().toISOString();
save();
stopAll();
await sleep(2000);
log(`done; summary at ${join(SCRATCH, 'summary.json')}`);
process.exit(0);