#!/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);