#!/usr/bin/env node // Igneum devnet transaction generator (5 October 2026). Funds N generated wallets from the devnet dev-fee key, then // sends transfers between them at a steady rate through one node's EVM JSON-RPC, so blocks carry content and the // proving layer gets shards with pgas above zero. Until this tool the devnet had no transactions at all: every shard // proven, verified and paid before 5 October 2026 was empty (0 pgas). // // tools/lock/with-lock.sh run node tools/txgen/run.mjs [--wallets 8] [--rate 2] [--duration 1200] [--fund 2] // [--cap 20] [--rpc http://127.0.0.1:26800] [--keys ~/.config/igneum/txgen/wallets.json] [--summary ] // // Options. --wallets: generated wallets (default 8). --rate: transfers per second (default 2, decimals allowed). // --duration: seconds of sending (default 1200); 0 sends until a signal. --fund: IGN sent to each wallet whose // balance is under half of it (default 2). --cap: the most IGN this run may spend, funding plus fees, before it stops // sending (default 20; values moved between the generated wallets stay in them and are not spend). --keys: the // wallets' keys, created at mode 0600 under ~/.config/igneum/txgen/ and reused on the next run (never in the repo). // A wallet keeps at most 14 transfers in flight (the pool refuses a nonce more than 16 ahead of the account; a tick // with every wallet full is counted throttled) and a pool refusal for queue depth is counted deferred, not failed. // Inclusion through ONE node is paced by the pool's hand-out cooldown (a sender's run is excluded from new templates // for 4 s after it is handed to one, and this node's miner finds about 1 block in 12), so 16 wallets at 2/s keep // every queue under the gap where 8 would not; the summary's throughput is what the chain actually included. // --funder: the dev-fee file (default ~/.config/igneum/dev-fee-devnet.json, devnet only). --summary: the summary // JSON (default next to the keys file, stamped with the start time). --stale: seconds before a transaction with no // receipt is checked with igneum_getTransactionStatus and counted dropped or skipped (default 90). // // Fees: the tip is the node's eth_maxPriorityFeePerGas (1 gwei on the devnet), the fee cap is the higher of the // node's eth_gasPrice quote (f_e + f_p x ratio + tip, 3 gwei on the devnet) and twice the execution base fee plus the // tip, re-read every 30 s. The gas limit is the node's eth_estimateGas for a transfer (it folds the proving charge: // 25,380 on the devnet) with a floor of 21,000. Nonces are tracked locally per wallet, re-synced from the node's // pending nonce (account nonce plus its queue) and the send retried once on "nonce too low". Sends from one wallet are serialised; the cadence is kept by a drift- // corrected timer, so a slow RPC answer does not stretch the run. // // Output: one line per 100 sends (sent, included, pending, failures, included rate, latency), one at every fee change // and at every funding step, and the summary JSON at the end. A clean stop (duration reached, SIGINT or SIGTERM) // stops sending, waits up to 60 s for the receipts, reads the final balances and writes the summary; the funds stay // in the generated wallets for the next run. No key is ever printed. // // Dependency: viem (EIP-1559 signing), the library tools/evm-smoke and tools/exec-attacks already use; `npm install` // in this folder once (node_modules is ignored). Node 22. import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'; const args = process.argv.slice(2); const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : dflt; }; if (args.includes('--help') || args.includes('-h')) { console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\n').filter(l => l.startsWith('//')).map(l => l.slice(3)).join('\n')); process.exit(0); } const RPC = opt('--rpc', 'http://127.0.0.1:26800'); const WALLETS = Math.max(2, +opt('--wallets', 8)); const RATE = +opt('--rate', 2); const DURATION = +opt('--duration', 1200); const STALE_MS = 1000 * +opt('--stale', 90); const TXGEN_DIR = join(homedir(), '.config', 'igneum', 'txgen'); const KEYS = opt('--keys', join(TXGEN_DIR, 'wallets.json')); const FUNDER_FILE = opt('--funder', join(homedir(), '.config', 'igneum', 'dev-fee-devnet.json')); const startedAt = new Date(); const stamp = startedAt.toISOString().replace(/[:.]/g, '-').slice(0, 19); const SUMMARY = opt('--summary', join(dirname(KEYS), `summary-${stamp}.json`)); const WEI = 10n ** 18n, GWEI = 10n ** 9n; const parseIgn = (s) => { const [i, f = ''] = String(s).split('.'); return BigInt(i || 0) * WEI + BigInt((f + '0'.repeat(18)).slice(0, 18)); }; const FUND = parseIgn(opt('--fund', '2')); const CAP = parseIgn(opt('--cap', '20')); const ign = (w) => (Number(w) / 1e18).toFixed(6); const gwei = (w) => (Number(w) / 1e9).toFixed(2); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a); if (!(RATE > 0) || !(DURATION >= 0)) { console.error('bad --rate or --duration'); process.exit(2); } // ---- JSON-RPC ---- let rpcId = 0; class RpcError extends Error { constructor(e) { super(typeof e === 'string' ? e : `${e.message || 'rpc error'}${e.data ? ` (${JSON.stringify(e.data)})` : ''}`); this.code = e.code; } } async function post(body) { const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(20000) }); if (!r.ok) throw new RpcError(`http ${r.status}`); return r.json(); } async function rpc(method, params = []) { const j = await post({ jsonrpc: '2.0', id: ++rpcId, method, params }); if (j.error) throw new RpcError(j.error); return j.result; } // a batch keeps the order of its calls; each entry is {result} or {error} async function rpcBatch(calls) { if (!calls.length) return []; const ids = calls.map(() => ++rpcId); const j = await post(calls.map(([method, params], i) => ({ jsonrpc: '2.0', id: ids[i], method, params }))); const byId = new Map((Array.isArray(j) ? j : [j]).map((e) => [e.id, e])); return ids.map((id) => { const e = byId.get(id); return e ? { result: e.result, error: e.error } : { error: { message: 'missing from the batch answer' } }; }); } // ---- keys ---- function loadFunder() { if (!existsSync(FUNDER_FILE)) throw new Error(`funder file missing: ${FUNDER_FILE}`); const f = JSON.parse(readFileSync(FUNDER_FILE, 'utf8')); if (!/devnet/i.test(f.purpose || '') || !f.private_key) throw new Error('the funder file is not the devnet dev-fee file'); const account = privateKeyToAccount(f.private_key); if (account.address.toLowerCase() !== String(f.address).toLowerCase()) throw new Error('the funder file address does not match its key'); return account; } function loadOrCreateWallets(chainId) { mkdirSync(dirname(KEYS), { recursive: true, mode: 0o700 }); let file = existsSync(KEYS) ? JSON.parse(readFileSync(KEYS, 'utf8')) : { purpose: 'Igneum DEVNET txgen wallets (devnet only, never mainnet)', chain_id: chainId, created: Math.floor(Date.now() / 1000), wallets: [] }; if (file.chain_id !== chainId) throw new Error(`${KEYS} is for chain ${file.chain_id}, the node is chain ${chainId}`); let added = 0; while (file.wallets.length < WALLETS) { const pk = generatePrivateKey(); file.wallets.push({ index: file.wallets.length, address: privateKeyToAccount(pk).address, private_key: pk }); added++; } if (added || !existsSync(KEYS)) { writeFileSync(KEYS, JSON.stringify(file, null, 2) + '\n', { mode: 0o600 }); chmodSync(KEYS, 0o600); } return { wallets: file.wallets.slice(0, WALLETS).map((w) => ({ i: w.index, address: w.address, account: privateKeyToAccount(w.private_key) })), added, total: file.wallets.length }; } // ---- state ---- const stats = { sent: 0, included: 0, failed: 0, dropped: 0, skipped: 0, reverted: 0, nonceRetries: 0, deferred: 0, throttled: 0, fundingTx: 0 }; const errors = new Map(); // message -> count const latencies = []; // ms, send to receipt const perBlock = new Map(); // block number -> included count const pending = new Map(); // hash -> {w, to, value, nonce, sentAt, nowhereAt} const lost = []; // dropped or skipped: {hash, wallet, nonce, verdict}, listed in the summary for a later check let spendFunding = 0n, spendFeesMax = 0n, spendFeesActual = 0n, valueMoved = 0n; let fees = { base: GWEI, pbase: GWEI, tip: GWEI, maxFee: 3n * GWEI, quoted: 3n * GWEI }; let GAS = 21000n, CHAIN = 0, running = true, stopReason = '', capHit = false; let firstSentAt = 0, lastIncludedAt = 0, firstBlock = 0, lastBlock = 0; let funder, wallets; const fail = (msg) => { stats.failed++; errors.set(msg, (errors.get(msg) || 0) + 1); }; async function refreshFees(announce) { const [gp, tipQ, fh] = await Promise.all([ rpc('eth_gasPrice'), rpc('eth_maxPriorityFeePerGas').catch(() => null), rpc('eth_feeHistory', ['0x1', 'latest', []]).catch(() => null), ]); const base = fh?.baseFeePerGas?.length ? BigInt(fh.baseFeePerGas.at(-1)) : GWEI; const pbase = fh?.igneum?.provingBaseFeePerGas?.length ? BigInt(fh.igneum.provingBaseFeePerGas.at(-1)) : GWEI; const tip = tipQ ? BigInt(tipQ) : GWEI; const quoted = BigInt(gp); const floor = 2n * base + tip; const maxFee = quoted > floor ? quoted : floor; const changed = maxFee !== fees.maxFee || tip !== fees.tip || base !== fees.base || pbase !== fees.pbase; fees = { base, pbase, tip, maxFee, quoted }; if (changed || announce) log(`fees: base ${gwei(base)} gwei, proving base ${gwei(pbase)} gwei, tip ${gwei(tip)} gwei, node quote ${gwei(quoted)} gwei, fee cap used ${gwei(maxFee)} gwei, gas ${GAS}`); } // the pool's pending nonce (rpc.rs: `pool.pending_nonce`, the account nonce plus the contiguous queue), so a resync // never collides with what this tool already queued; 'latest' only when the node refuses the tag const chainNonce = (address) => rpc('eth_getTransactionCount', [address, 'pending']).catch(() => rpc('eth_getTransactionCount', [address, 'latest'])).then((n) => Number(BigInt(n))); const balanceOf = (address) => rpc('eth_getBalance', [address, 'latest']).then(BigInt); async function refreshBalances() { const res = await rpcBatch(wallets.map((w) => ['eth_getBalance', [w.address, 'latest']])); res.forEach((r, i) => { if (r.result !== undefined) wallets[i].balance = BigInt(r.result); }); } async function signAndSend(account, fields) { const raw = await account.signTransaction({ type: 'eip1559', chainId: CHAIN, gas: GAS, maxFeePerGas: fees.maxFee, maxPriorityFeePerGas: fees.tip, ...fields }); return rpc('eth_sendRawTransaction', [raw]); } // ---- funding ---- async function fund() { const bal = await balanceOf(funder.address); let nonce = await chainNonce(funder.address); const need = []; for (const w of wallets) { w.balance = await balanceOf(w.address); if (w.balance < FUND / 2n) need.push(w); } log(`funder ${funder.address}: ${ign(bal)} IGN, nonce ${nonce}; ${need.length} of ${wallets.length} wallets need ${ign(FUND)} IGN`); if (!need.length) return; const total = FUND * BigInt(need.length) + GAS * fees.maxFee * BigInt(need.length); if (total > CAP) throw new Error(`funding ${ign(total)} IGN is over the cap ${ign(CAP)} IGN`); if (bal < total) throw new Error(`funder holds ${ign(bal)} IGN, funding needs ${ign(total)} IGN`); const hashes = []; for (const w of need) { let hash; for (let attempt = 0; attempt < 2 && !hash; attempt++) { try { hash = await signAndSend(funder, { nonce, to: w.address, value: FUND }); } catch (e) { if (attempt === 0 && /nonce/i.test(e.message)) { nonce = await chainNonce(funder.address); stats.nonceRetries++; } else throw new Error(`funding wallet ${w.i}: ${e.message}`); } } nonce++; stats.fundingTx++; spendFunding += FUND; hashes.push([w, hash]); } const t0 = Date.now(); const left = new Map(hashes.map(([w, h]) => [h, w])); while (left.size && Date.now() - t0 < 180000) { await sleep(1000); const hs = [...left.keys()]; const res = await rpcBatch(hs.map((h) => ['eth_getTransactionReceipt', [h]])); res.forEach((r, i) => { if (!r.result) return; const w = left.get(hs[i]); left.delete(hs[i]); if (r.result.status !== '0x1') throw new Error(`funding of wallet ${w.i} reverted (${hs[i]})`); spendFeesActual += BigInt(r.result.gasUsed) * BigInt(r.result.effectiveGasPrice || fees.maxFee); log(`funded wallet ${w.i} ${w.address} in block ${Number(BigInt(r.result.blockNumber))} (${((Date.now() - t0) / 1000).toFixed(1)} s)`); }); } if (left.size) throw new Error(`${left.size} funding transactions have no receipt after 180 s`); await refreshBalances(); } // ---- sending ---- let rr = 0; function pickPair() { for (let k = 0; k < wallets.length; k++) { const w = wallets[(rr + k) % wallets.length]; const value = GWEI * BigInt(100000 + Math.floor(Math.random() * 900000)); // 0.0001 to 0.001 IGN const maxCost = value + GAS * fees.maxFee; // at most 14 in flight per wallet: the pool refuses a nonce more than 16 ahead of the account (pool.rs MAX_NONCE_GAP) if (w.pendingCount >= 14 || (w.deferUntil || 0) > Date.now() || w.balance < maxCost * BigInt(w.pendingCount + 2)) continue; rr = (rr + k + 1) % wallets.length; const to = wallets[(w.i + 1 + Math.floor(Math.random() * (wallets.length - 1))) % wallets.length]; return { w, to, value }; } return null; } async function sendOne(w, to, value) { if (capHit) return; if (spendFunding + spendFeesMax + GAS * fees.maxFee > CAP) { capHit = true; log(`spend cap ${ign(CAP)} IGN reached: funding ${ign(spendFunding)} + fees at most ${ign(spendFeesMax)}; no more sends`); return; } let nonce = w.nonce; for (let attempt = 0; attempt < 2; attempt++) { try { const hash = await signAndSend(w.account, { nonce, to: to.address, value }); w.nonce = nonce + 1; w.pendingCount++; w.sent++; stats.sent++; spendFeesMax += GAS * fees.maxFee; if (!firstSentAt) firstSentAt = Date.now(); pending.set(hash, { w, to, value, nonce, sentAt: Date.now() }); if (stats.sent % 100 === 0) progress(); return; } catch (e) { const m = e.message || String(e); // the pool's own back-pressure (pool.rs: MAX_NONCE_GAP 16, MAX_QUEUED_PER_SENDER 64): the nonce was not // consumed, the wallet waits for an inclusion; counted as deferred, not as a failure if (/nonce gap|too many queued/i.test(m)) { stats.deferred++; w.deferUntil = Date.now() + 3000; return; } // the chain is past our count (a dropped transaction, or another sender of this key): take the chain's nonce // from the message or the RPC and retry once if (attempt === 0 && /nonce too low/i.test(m)) { nonce = await chainNonce(w.address); w.nonce = nonce; stats.nonceRetries++; continue; } fail(m.slice(0, 160)); // a collision with this wallet's own queue (after a resync that went too low): follow the pool's pending nonce if (/already known|replacement|underpriced|nonce too low/i.test(m)) w.nonce = await chainNonce(w.address).catch(() => w.nonce); return; } } } function progress(final) { const span = lastIncludedAt > firstSentAt ? (lastIncludedAt - firstSentAt) / 1000 : 0; const lat = latencies.length ? latencies.slice().sort((a, b) => a - b) : []; const p = (q) => lat.length ? (lat[Math.min(lat.length - 1, Math.floor(q * lat.length))] / 1000).toFixed(1) : '-'; log(`${final ? 'final:' : ''} sent ${stats.sent}, included ${stats.included}, pending ${pending.size}, failures ${stats.failed} (dropped ${stats.dropped}, skipped ${stats.skipped}, reverted ${stats.reverted}, nonce retries ${stats.nonceRetries}, deferred ${stats.deferred}, throttled ${stats.throttled}); included ${span ? (stats.included / span).toFixed(2) : '-'}/s over ${span.toFixed(0)} s, latency p50 ${p(0.5)} s p90 ${p(0.9)} s, blocks with content ${perBlock.size}, fees ${ign(spendFeesActual)} IGN`); } // ---- receipts ---- let polling = false; async function poll() { if (polling || !pending.size) return; polling = true; try { const hashes = [...pending.keys()]; for (let i = 0; i < hashes.length; i += 50) { const chunk = hashes.slice(i, i + 50); const res = await rpcBatch(chunk.map((h) => ['eth_getTransactionReceipt', [h]])); const stale = []; res.forEach((r, k) => { const h = chunk[k], p = pending.get(h); if (!p) return; if (r.result) { const rc = r.result, bn = Number(BigInt(rc.blockNumber)); pending.delete(h); p.w.pendingCount--; const feePaid = BigInt(rc.gasUsed) * BigInt(rc.effectiveGasPrice || fees.maxFee); spendFeesActual += feePaid; if (rc.status === '0x1') { stats.included++; valueMoved += p.value; latencies.push(Date.now() - p.sentAt); perBlock.set(bn, (perBlock.get(bn) || 0) + 1); lastIncludedAt = Date.now(); if (!firstBlock || bn < firstBlock) firstBlock = bn; if (bn > lastBlock) lastBlock = bn; p.w.balance -= p.value + feePaid; p.to.balance += p.value; } else { stats.reverted++; fail('receipt status 0x0 (reverted transfer)'); } } else if (Date.now() - p.sentAt > STALE_MS) stale.push(h); }); if (stale.length) { const st = await rpcBatch(stale.map((h) => ['igneum_getTransactionStatus', [h]])); st.forEach((r, k) => { const h = stale[k], p = pending.get(h); if (!p || !r.result) return; const s = r.result; if (s.inMempool) { p.nowhereAt = null; return; } // still waiting, the node has it // "in no block and not in the pool" is transient around a one-block reorg (service.rs unwinds the // records and tx index, pool.rs removed the transaction when the chain block executed it, and the // carrying block is re-merged seconds later), so a drop needs two such verdicts 60 s apart if (!s.includedIn?.length) { if (!p.nowhereAt) { p.nowhereAt = Date.now(); return; } if (Date.now() - p.nowhereAt < 60000) return; } pending.delete(h); p.w.pendingCount--; if (s.includedIn?.length && !s.executed) { stats.skipped++; lost.push({ hash: h, wallet: p.w.i, nonce: p.nonce, verdict: 'skipped' }); fail(`skipped in a block with no receipt after ${STALE_MS / 1000} s (state-dependent fault: nonce or balance)`); } else { stats.dropped++; lost.push({ hash: h, wallet: p.w.i, nonce: p.nonce, verdict: 'dropped' }); fail(`dropped: not in the mempool and in no block on two checks 60 s apart`); } chainNonce(p.w.address).then((n) => { p.w.nonce = n; }).catch(() => { }); // the next send fills the gap the drop left }); } } } catch (e) { errors.set(`poll: ${e.message.slice(0, 120)}`, (errors.get(`poll: ${e.message.slice(0, 120)}`) || 0) + 1); } finally { polling = false; } } // ---- summary ---- function writeSummary() { const lat = latencies.slice().sort((a, b) => a - b); const q = (x) => lat.length ? lat[Math.min(lat.length - 1, Math.floor(x * lat.length))] : null; const blocks = [...perBlock.entries()].sort((a, b) => a[0] - b[0]); const span = lastIncludedAt > firstSentAt ? (lastIncludedAt - firstSentAt) / 1000 : 0; const summary = { tool: 'tools/txgen/run.mjs', rpc: RPC, chain_id: CHAIN, started: startedAt.toISOString(), ended: new Date().toISOString(), stop_reason: stopReason, params: { wallets: WALLETS, rate_per_s: RATE, duration_s: DURATION, fund_ign: ign(FUND), cap_ign: ign(CAP), gas: Number(GAS), stale_s: STALE_MS / 1000 }, fees_last: { base_gwei: gwei(fees.base), proving_base_gwei: gwei(fees.pbase), tip_gwei: gwei(fees.tip), node_quote_gwei: gwei(fees.quoted), fee_cap_gwei: gwei(fees.maxFee) }, counts: { ...stats, pending_at_end: pending.size }, throughput: { included_per_s: span ? +(stats.included / span).toFixed(3) : null, send_span_s: +span.toFixed(1), sent_per_s_target: RATE }, latency_ms: { p50: q(0.5), p90: q(0.9), p99: q(0.99), max: lat.at(-1) ?? null, mean: lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : null }, blocks: { with_content: blocks.length, first: firstBlock || null, last: lastBlock || null, max_tx_in_one: blocks.length ? Math.max(...blocks.map(([, n]) => n)) : 0, per_block: Object.fromEntries(blocks) }, spend_ign: { funding: ign(spendFunding), fees_actual: ign(spendFeesActual), fees_max_committed: ign(spendFeesMax), value_moved_between_wallets: ign(valueMoved), cap: ign(CAP), cap_hit: capHit }, wallets: wallets.map((w) => ({ index: w.i, address: w.address, sent: w.sent, balance_ign: ign(w.balance), nonce: w.nonce })), funder: { address: funder.address, balance_ign: funder.balance != null ? ign(funder.balance) : null }, lost: lost, pending_at_end: [...pending.entries()].map(([h, p]) => ({ hash: h, wallet: p.w.i, nonce: p.nonce, age_s: Math.round((Date.now() - p.sentAt) / 1000) })), keys_file: KEYS, errors: Object.fromEntries([...errors.entries()].sort((a, b) => b[1] - a[1])), }; mkdirSync(dirname(SUMMARY), { recursive: true }); writeFileSync(SUMMARY, JSON.stringify(summary, null, 2) + '\n'); return summary; } // ---- main ---- let signals = 0; const onSignal = (sig) => { signals++; if (signals === 1) { stopReason = sig; running = false; log(`${sig}: stopping sends, waiting for receipts (again to quit at once)`); } else { stopReason = `${sig} x2`; writeSummary(); process.exit(130); } }; process.on('SIGINT', () => onSignal('SIGINT')); process.on('SIGTERM', () => onSignal('SIGTERM')); try { CHAIN = Number(BigInt(await rpc('eth_chainId'))); const tip0 = Number(BigInt(await rpc('eth_blockNumber'))); if (CHAIN !== 4463) throw new Error(`chain id ${CHAIN} is not the devnet (4463); this tool funds from the devnet dev-fee key only`); funder = loadFunder(); const lw = loadOrCreateWallets(CHAIN); wallets = lw.wallets; for (const w of wallets) { w.nonce = 0; w.balance = 0n; w.pendingCount = 0; w.sent = 0; } log(`chain ${CHAIN} at block ${tip0} via ${RPC}; ${wallets.length} wallets from ${KEYS} (${lw.added} new, ${lw.total} on file); rate ${RATE}/s for ${DURATION || 'unbounded'} s, cap ${ign(CAP)} IGN`); const est = await rpc('eth_estimateGas', [{ from: funder.address, to: wallets[0].address, value: '0x1' }]).then(BigInt).catch(() => 21000n); GAS = est > 21000n ? est : 21000n; await refreshFees(true); await fund(); const nonces = await rpcBatch(wallets.map((w) => ['eth_getTransactionCount', [w.address, 'pending']])); nonces.forEach((r, i) => { wallets[i].nonce = r.result ? Number(BigInt(r.result)) : 0; }); log(`wallets: ${wallets.map((w) => `${w.i}:${ign(w.balance).replace(/0+$/, '')} IGN n${w.nonce}`).join(', ')}`); const period = 1000 / RATE; const endAt = DURATION ? Date.now() + DURATION * 1000 : Infinity; let next = Date.now(), lastFees = Date.now(), lastBal = Date.now(); const pollTimer = setInterval(() => { poll(); }, 1500); log(`sending ${RATE} transfers/s between ${wallets.length} wallets`); while (running) { if (Date.now() >= endAt) { stopReason = 'duration'; break; } if (capHit) { stopReason = 'cap'; break; } const pair = pickPair(); if (pair) { const { w, to, value } = pair; w.chain = (w.chain || Promise.resolve()).then(() => sendOne(w, to, value)).catch(() => { }); } else stats.throttled++; if (Date.now() - lastFees > 30000) { lastFees = Date.now(); refreshFees(false).catch((e) => log('fee refresh failed:', e.message)); } if (Date.now() - lastBal > 30000) { lastBal = Date.now(); refreshBalances().catch(() => { }); } next += period; const wait = next - Date.now(); if (wait > 0) await sleep(wait); else if (wait < -5000) next = Date.now(); } log(`sending stopped (${stopReason}); ${pending.size} pending`); await Promise.all(wallets.map((w) => w.chain || Promise.resolve())); const drainUntil = Date.now() + 60000; while (pending.size && Date.now() < drainUntil) { await sleep(1500); await poll(); } clearInterval(pollTimer); await refreshBalances(); funder.balance = await balanceOf(funder.address).catch(() => null); progress(true); const s = writeSummary(); log(`summary: ${SUMMARY}`); log(`wallets hold ${ign(wallets.reduce((a, w) => a + w.balance, 0n))} IGN in all; funder ${s.funder.balance_ign ?? '?'} IGN; spend ${ign(spendFunding + spendFeesActual)} IGN (funding ${ign(spendFunding)}, fees ${ign(spendFeesActual)})`); if (errors.size) for (const [m, n] of errors) log(`error x${n}: ${m}`); process.exit(stats.included > 0 || stats.sent === 0 ? 0 : 1); } catch (e) { console.error('FAILED:', e.message); try { if (funder && wallets) writeSummary(); } catch { } process.exit(1); }