igneum/tools/txgen/run.mjs
igneum-labs fa6bbb0d5f txgen: real transactions on the devnet, the first non-empty shard proven and paid, the exporter's block structure fixed
tools/txgen/run.mjs funds generated wallets from the devnet dev-fee key and sends transfers at a steady rate through
one node (nonce tracking from the pool's pending nonce, two-strike drop verdict, pool back-pressure counted as
deferred, spend cap, clean stop, summary JSON); tools/txgen/proving-watch.mjs watches the proving layer and builds the
per-block report. Two runs through the Mac node: 2,275 sent at 2/s, 2,161 included at 1.86/s (run 1), 1,650 sent,
1,633 included at 1.71/s with no failure (run 2, fixed code). Block 72704 shard 0 (29 transfers, 5,800 pgas) proven on
PC 2 in 34 s, verified on the Mac in 0.297 s, paid 1.7623 IGN.

Block 72803 (seven skipped copies, no executed transaction) failed the native-execution veto: the exporter rebuilt the
including blocks from an export that names no block, position or skipped copy's miner, sorting skipped copies out
of their block, merging consecutive blocks of one miner, dropping empty blocks (the node counts them in the link's
block index) and guessing the zero address. blocks_of now rebuilds from the 0.3.9 export's "blocks", "block" and
"position" fields (the fork change on vendor/igneum-node-txgen branch txgen-export), keeps an old export in its
order and refuses a skipped-only block without a miner. Fixtures block-72803-skipped-copies and
block-72854-empty-block-first with the node's shard plan beside each; the fixture test now checks the cut's links,
roots, gas, pgas and counts against the node's plan (shown failing on the old 72854 cut). No change under core/.

Bench-log entry and evidence rows 15 and 21.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 16:18:32 +00:00

380 lines
24 KiB
JavaScript

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