197 lines
16 KiB
JavaScript
197 lines
16 KiB
JavaScript
#!/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 <dir>]
|
|
// [--node <igneumd>] [--miner <igneum-miner>] [--pool <igneum-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 <scratch>/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/<address>, /api/payments on the measure pool; miners rig1..3 STATUS lines in <scratch>/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);
|