igneum/tools/evm-smoke/smoke.mjs
igneum-labs 585a9e7f0c Execution layer devnet v3: implementation notes, bench entry, viem smoke test
docs/design/execution-layer.md section 10: what the execution-layer branch implements
(D1 to D10, RPC, differential), the devnet rules fixed there, what is missing, the merge
plan with the finality branch. docs/bench-log.md: the 3-node simnet run with numbers.
tools/evm-smoke: viem 2.57 smoke test (fund, 50 transfers, duplicates in parallel blocks,
contract deploy and call, state roots across nodes, export for igneum-exec-diff) and the
solc build of DeveloperRegistry and the Counter test contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 22:05:33 +00:00

252 lines
18 KiB
JavaScript

// Igneum devnet v3 execution-layer smoke test with stock Ethereum tooling (viem).
//
// Against three igneumd nodes (eth_ JSON-RPC on 26790, 26791, 26792 by default):
// 1. chain id, block numbers, the funded miner (its EVM address is the low 20 bytes of the vote key hash)
// 2. fund three accounts from the miner's rewards
// 3. 50 transfers between the three accounts across several blocks, sent round-robin to the three nodes
// 4. duplicates in parallel blocks: one identical copy sent to two nodes, one conflicting same-nonce pair
// 5. deploy a contract, call it, read receipts, logs, eth_call, a revert, the developer share
// 6. state root advancing and identical across the nodes for the same chain block
// Prints the numbers the bench log needs and writes seq.json (igneum_exportSegments) for igneum-exec-diff.
import { createPublicClient, createWalletClient, http, defineChain, parseEther, formatEther, encodeFunctionData, decodeFunctionResult, keccak256, toHex, parseAbi } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { readFileSync, writeFileSync } from 'node:fs';
const urls = (process.env.IGNEUM_RPCS ?? 'http://127.0.0.1:26790,http://127.0.0.1:26791,http://127.0.0.1:26792').split(',');
const chain = defineChain({ id: 4463, name: 'Igneum devnet', nativeCurrency: { name: 'Igneum', symbol: 'IGN', decimals: 18 }, rpcUrls: { default: { http: [urls[0]] } } });
const clients = urls.map((u) => createPublicClient({ chain, transport: http(u, { timeout: 20_000 }) }));
const node1 = clients[0];
// Keys for the test only (never used anywhere else). Miner 1's EVM address derives from this key; the node was
// started with --vote-key-hash 0x000000000000000000000000<address>.
const minerKey = process.env.IGNEUM_MINER_KEY ?? '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d';
const miner = privateKeyToAccount(minerKey);
const A = privateKeyToAccount('0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba');
const B = privateKeyToAccount('0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a');
const C = privateKeyToAccount('0x8166f546bab6da521a8369cab06c5d2b9e46670292d85c875ee9ec20e84ba4ea');
const D = privateKeyToAccount('0xea6c44ac03bff858b476bba40716402b03e41b8e97e276d1baec7c37d42484a0'); // developer payee
const rpc = async (client, method, params = []) => client.request({ method, params });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = { steps: [], blocks: {} };
function log(...a) { console.log(new Date().toISOString().slice(11, 19), ...a); }
function assert(cond, msg) { if (!cond) { console.error('ASSERTION FAILED:', msg); results.steps.push({ fail: msg }); process.exitCode = 1; } else { results.steps.push({ ok: msg }); } }
async function waitReceipts(hashes, timeoutMs = 90_000) {
const out = {};
const start = Date.now();
while (Object.keys(out).length < hashes.length && Date.now() - start < timeoutMs) {
for (const h of hashes) {
if (out[h]) continue;
const r = await rpc(node1, 'eth_getTransactionReceipt', [h]);
if (r) out[h] = r;
}
if (Object.keys(out).length < hashes.length) await sleep(400);
}
return out;
}
async function signAndSend(account, tx, client) {
const serialized = await account.signTransaction({ chainId: 4463, type: 'eip1559', ...tx });
const hash = await rpc(client, 'eth_sendRawTransaction', [serialized]);
return { hash, serialized };
}
async function main() {
const t0 = Date.now();
// 1. chain id and the funded miner
const chainId = await rpc(node1, 'eth_chainId');
assert(chainId === '0x116f', `eth_chainId is 4463 (got ${chainId})`);
const netVersion = await rpc(node1, 'net_version');
assert(netVersion === '4463', `net_version 4463 (got ${netVersion})`);
log('miner EVM address', miner.address);
let minerBalance = 0n;
for (let i = 0; i < 120; i++) {
minerBalance = BigInt(await rpc(node1, 'eth_getBalance', [miner.address, 'latest']));
if (minerBalance > parseEther('20')) break;
await sleep(1000);
}
const bn0 = Number(await rpc(node1, 'eth_blockNumber'));
log('chain block', bn0, 'miner balance', formatEther(minerBalance), 'IGN');
assert(minerBalance > parseEther('20'), 'miner has rewards (80% of the subsidy per blue block it mined)');
const budgets = await rpc(node1, 'igneum_getBudgets');
log('budgets', budgets);
// 2. fund A, B, C
const gasPrice = BigInt(await rpc(node1, 'eth_gasPrice'));
const block0 = await rpc(node1, 'eth_getBlockByNumber', ['latest', false]);
const baseFee = BigInt(block0.baseFeePerGas);
log('eth_gasPrice (folded)', gasPrice.toString(), 'baseFeePerGas', baseFee.toString(), 'provingBaseFeePerGas', BigInt(block0.igneum.provingBaseFeePerGas).toString());
const fees = { maxFeePerGas: baseFee * 2n + parseEther('0.000000002'), maxPriorityFeePerGas: parseEther('0.000000001') };
let minerNonce = Number(await rpc(node1, 'eth_getTransactionCount', [miner.address, 'pending']));
const fundHashes = [];
for (const to of [A, B, C]) {
const est = BigInt(await rpc(node1, 'eth_estimateGas', [{ from: miner.address, to: to.address, value: toHex(parseEther('5')) }]));
const { hash } = await signAndSend(miner, { to: to.address, value: parseEther('5'), nonce: minerNonce++, gas: est, ...fees }, node1);
fundHashes.push(hash);
log('fund', to.address, 'tx', hash, 'estimateGas', est.toString());
}
const fundReceipts = await waitReceipts(fundHashes);
assert(Object.keys(fundReceipts).length === 3, 'three funding transfers got receipts');
for (const h of fundHashes) assert(fundReceipts[h]?.status === '0x1', `funding ${h} status 1`);
for (const acc of [A, B, C]) {
const bal = BigInt(await rpc(node1, 'eth_getBalance', [acc.address, 'latest']));
assert(bal === parseEther('5'), `${acc.address} holds 5 IGN (got ${formatEther(bal)})`);
}
// 3. 50 transfers between A, B, C across several blocks, round-robin across the three nodes
const accounts = [A, B, C];
const nonces = {};
for (const acc of accounts) nonces[acc.address] = Number(await rpc(node1, 'eth_getTransactionCount', [acc.address, 'latest']));
const sent = [];
const expected = { [A.address]: parseEther('5'), [B.address]: parseEther('5'), [C.address]: parseEther('5') };
const tStart = Date.now();
for (let i = 0; i < 50; i++) {
const from = accounts[i % 3];
const to = accounts[(i + 1 + (i % 2)) % 3];
const value = parseEther('0.01') * BigInt(1 + (i % 7));
const client = clients[i % clients.length];
const { hash } = await signAndSend(from, { to: to.address, value, nonce: nonces[from.address]++, gas: 50_000n, ...fees }, client);
sent.push({ hash, from: from.address, to: to.address, value });
if (i % 10 === 9) await sleep(700); // spread over several blocks
}
const receipts = await waitReceipts(sent.map((s) => s.hash), 120_000);
const tEnd = Date.now();
assert(Object.keys(receipts).length === 50, `all 50 transfers executed (got ${Object.keys(receipts).length}) in ${((tEnd - tStart) / 1000).toFixed(1)} s`);
const perBlock = {};
for (const s of sent) {
const r = receipts[s.hash];
if (!r) continue;
assert(r.status === '0x1', `transfer ${s.hash} status 1`);
perBlock[Number(r.blockNumber)] = (perBlock[Number(r.blockNumber)] ?? 0) + 1;
const fee = BigInt(r.gasUsed) * BigInt(r.effectiveGasPrice) + BigInt(r.igneum.burnedProvingFee);
expected[s.from] -= s.value + fee;
expected[s.to] += s.value;
}
log('transfers per chain block', perBlock);
for (const acc of accounts) {
const bal = BigInt(await rpc(node1, 'eth_getBalance', [acc.address, 'latest']));
assert(bal === expected[acc.address], `${acc.address} balance matches receipts accounting (node ${formatEther(bal)}, expected ${formatEther(expected[acc.address])})`);
}
const sample = receipts[sent[0].hash];
log('sample receipt', { gasUsed: sample.gasUsed, pgasUsed: sample.igneum.pgasUsed, effectiveGasPrice: sample.igneum ? sample.effectiveGasPrice : null, burnedProvingFee: sample.igneum.burnedProvingFee, minerTip: sample.igneum.minerTip, developerShares: sample.igneum.developerShares });
// 4. duplicates in parallel blocks (design 1.3, 1.4). Retry injection until the DAG gives parallel inclusion.
let dupResult = null;
for (let attempt = 0; attempt < 6 && !dupResult; attempt++) {
const nonceA = Number(await rpc(node1, 'eth_getTransactionCount', [A.address, 'latest']));
const nonceB = Number(await rpc(node1, 'eth_getTransactionCount', [B.address, 'latest']));
// (a) identical copy to two nodes
const serA = await A.signTransaction({ chainId: 4463, type: 'eip1559', to: C.address, value: parseEther('0.001'), nonce: nonceA, gas: 30_000n, ...fees });
// (b) conflicting pair: same sender and nonce, different value, one copy per node
const serB1 = await B.signTransaction({ chainId: 4463, type: 'eip1559', to: C.address, value: parseEther('0.002'), nonce: nonceB, gas: 30_000n, ...fees });
const serB2 = await B.signTransaction({ chainId: 4463, type: 'eip1559', to: A.address, value: parseEther('0.003'), nonce: nonceB, gas: 30_000n, maxFeePerGas: fees.maxFeePerGas + parseEther('0.000000001'), maxPriorityFeePerGas: fees.maxPriorityFeePerGas + parseEther('0.000000001') });
const hA = keccak256(serA), hB1 = keccak256(serB1), hB2 = keccak256(serB2);
const sends = await Promise.allSettled([
rpc(clients[0], 'eth_sendRawTransaction', [serA]), rpc(clients[1 % clients.length], 'eth_sendRawTransaction', [serA]),
rpc(clients[0], 'eth_sendRawTransaction', [serB1]), rpc(clients[2 % clients.length], 'eth_sendRawTransaction', [serB2]),
]);
log('duplicate sends', sends.map((s) => s.status === 'fulfilled' ? 'ok' : s.reason?.message?.slice(0, 60)));
await sleep(6000);
const st = {};
for (const h of [hA, hB1, hB2]) st[h] = await rpc(node1, 'igneum_getTransactionStatus', [h]);
const incA = st[hA].includedIn.length, incB = st[hB1].includedIn.length + st[hB2].includedIn.length;
log(`attempt ${attempt}: identical copy included ${incA} times (executed ${st[hA].executed}); conflicting pair included ${incB} times (B1 executed ${st[hB1].executed}, B2 executed ${st[hB2].executed})`);
if (incA >= 2 || incB >= 2) {
dupResult = { hA: st[hA], hB1: st[hB1], hB2: st[hB2] };
if (incA >= 2) {
assert(st[hA].includedIn.filter((i) => i.executed).length === 1, 'identical copy: exactly one inclusion executed');
assert(st[hA].includedIn.filter((i) => !i.executed && /NonceTooLow/.test(i.skipReason ?? '')).length === incA - 1, 'identical copy: the other inclusions skipped by the nonce rule');
}
if (incB >= 2) {
assert(Number(st[hB1].executed) + Number(st[hB2].executed) === 1, 'conflicting pair: exactly one executed');
const loser = st[hB1].executed ? st[hB2] : st[hB1];
assert(loser.includedIn.every((i) => !i.executed && /NonceTooLow/.test(i.skipReason ?? '')), 'conflicting pair: the loser was skipped by the nonce rule wherever it was included');
}
results.duplicates = dupResult;
log('duplicate statuses', JSON.stringify(dupResult));
}
}
assert(dupResult !== null, 'duplicates landed in parallel blocks within 6 attempts');
// 5. deploy a contract through the stock wallet path (estimateGas, fee estimation, nonce), then call it
const counter = JSON.parse(readFileSync(new URL('./counter.json', import.meta.url), 'utf8'));
const wallet = createWalletClient({ account: A, chain, transport: http(urls[0], { timeout: 20_000 }) });
const registry = '0x0000000000000000000000000000000000000210';
const deployHash = await wallet.deployContract({ abi: counter.abi, bytecode: counter.bytecode, args: [registry, D.address] });
log('deploy tx', deployHash);
const deployReceipt = (await waitReceipts([deployHash]))[deployHash];
assert(deployReceipt?.status === '0x1', 'contract deployment status 1');
const counterAddr = deployReceipt?.contractAddress;
const code = await rpc(node1, 'eth_getCode', [counterAddr, 'latest']);
assert(code && code.length > 10, `deployed code present at ${counterAddr} (${(code.length - 2) / 2} bytes)`);
const regAbi = parseAbi(['function payeeOf(address) view returns (address)', 'function creatorOf(address) view returns (address)']);
const payee = await node1.readContract({ address: registry, abi: regAbi, functionName: 'payeeOf', args: [counterAddr] });
assert(payee.toLowerCase() === D.address.toLowerCase(), `registry payeeOf(counter) is D (${payee})`);
const creator = await node1.readContract({ address: registry, abi: regAbi, functionName: 'creatorOf', args: [counterAddr] });
assert(creator.toLowerCase() === A.address.toLowerCase(), `registry creatorOf(counter) is the deployer (${creator})`);
const dBefore = BigInt(await rpc(node1, 'eth_getBalance', [D.address, 'latest']));
const incHash = await wallet.writeContract({ address: counterAddr, abi: counter.abi, functionName: 'increment', args: [5n] });
const incReceipt = (await waitReceipts([incHash]))[incHash];
assert(incReceipt?.status === '0x1', 'increment(5) status 1');
assert(incReceipt?.logs?.length === 1, `increment emitted one log (got ${incReceipt?.logs?.length})`);
const count = await node1.readContract({ address: counterAddr, abi: counter.abi, functionName: 'count' });
assert(count === 5n, `eth_call count() == 5 (got ${count})`);
const dAfter = BigInt(await rpc(node1, 'eth_getBalance', [D.address, 'latest']));
const sharesToD = (incReceipt?.igneum?.developerShares ?? []).filter((s) => s.payee && s.payee.toLowerCase() === D.address.toLowerCase()).reduce((a, s) => a + BigInt(s.wei), 0n);
assert(sharesToD > 0n && dAfter - dBefore === sharesToD, `developer share landed at D: +${sharesToD} wei (balance delta ${dAfter - dBefore})`);
log('increment receipt', { gasUsed: incReceipt.gasUsed, pgasUsed: incReceipt.igneum.pgasUsed, minerTip: incReceipt.igneum.minerTip, developerShares: incReceipt.igneum.developerShares });
const logs = await rpc(node1, 'eth_getLogs', [{ fromBlock: incReceipt.blockNumber, toBlock: incReceipt.blockNumber, address: counterAddr }]);
assert(logs.length === 1, 'eth_getLogs finds the Incremented event');
let reverted = false;
try { await rpc(node1, 'eth_estimateGas', [{ from: A.address, to: counterAddr, data: encodeFunctionData({ abi: counter.abi, functionName: 'increment', args: [0n] }) }]); } catch (e) { reverted = /revert/i.test(e.message ?? '') || e.code === 3; }
assert(reverted, 'eth_estimateGas reports the revert of increment(0)');
const hashLoop = await rpc(node1, 'eth_call', [{ to: counterAddr, data: encodeFunctionData({ abi: counter.abi, functionName: 'hashLoop', args: [50n] }) }, 'latest']);
assert(hashLoop.length === 66, 'eth_call hashLoop(50) returns a bytes32');
const estLoop = BigInt(await rpc(node1, 'eth_estimateGas', [{ from: A.address, to: counterAddr, data: encodeFunctionData({ abi: counter.abi, functionName: 'hashLoop', args: [200n] }) }]));
log('estimateGas hashLoop(200) with the pgas fold', estLoop.toString());
// 6. state root advancing and identical across nodes
const tip = Number(await rpc(node1, 'eth_blockNumber'));
const heights = [0, Number(fundReceipts[fundHashes[0]].blockNumber), Number(incReceipt.blockNumber), tip];
const roots = {};
for (const n of heights) {
const blocks = await Promise.all(clients.map((c) => rpc(c, 'eth_getBlockByNumber', [toHex(n), false]).catch(() => null)));
roots[n] = blocks.map((b) => b && { hash: b.hash, stateRoot: b.stateRoot, txs: b.transactions.length, micros: Number(b.igneum.executionMicros), gasUsed: Number(b.gasUsed), pgasUsed: Number(b.igneum.pgasUsed), mergeset: b.igneum.mergeset.length });
const defined = roots[n].filter(Boolean);
assert(defined.length === clients.length, `all nodes have chain block ${n}`);
assert(defined.every((b) => b.hash === defined[0].hash && b.stateRoot === defined[0].stateRoot), `chain block ${n}: same hash and state root on every node (${defined[0]?.stateRoot})`);
}
assert(roots[heights[1]][0].stateRoot !== roots[0][0].stateRoot && roots[heights[2]][0].stateRoot !== roots[heights[1]][0].stateRoot, 'state root advances across blocks with transactions');
log('state roots', JSON.stringify(roots, null, 0));
// execution-time and txs-per-block numbers over every chain block with transactions
const stats = [];
let empties = 0, emptyMicros = 0;
for (let n = 1; n <= tip; n++) {
const b = await rpc(node1, 'eth_getBlockByNumber', [toHex(n), false]);
if (b.transactions.length > 0) stats.push({ n, txs: b.transactions.length, micros: Number(b.igneum.executionMicros), gasUsed: Number(b.gasUsed), pgasUsed: Number(b.igneum.pgasUsed), mergeset: b.igneum.mergeset.length, skipped: b.igneum.skipped.length, stateRoot: b.stateRoot, baseFee: b.baseFeePerGas });
else { empties++; emptyMicros += Number(b.igneum.executionMicros); }
}
results.stats = stats;
results.empties = { count: empties, avgMicros: empties ? emptyMicros / empties : 0 };
const totalTxs = stats.reduce((a, s) => a + s.txs, 0);
log(`chain blocks with transactions: ${stats.length}, total executed ${totalTxs}, max per block ${Math.max(...stats.map((s) => s.txs))}, exec micros per block: ${stats.map((s) => s.micros).join(',')}; empty blocks ${empties} avg ${results.empties.avgMicros.toFixed(0)} us`);
const budgetsEnd = await rpc(node1, 'igneum_getBudgets');
log('budgets at end', budgetsEnd);
results.budgets = budgetsEnd;
const miner1Final = BigInt(await rpc(node1, 'eth_getBalance', [miner.address, 'latest']));
const pool = BigInt(await rpc(node1, 'eth_getBalance', ['0x0000000000000000000000000000000000000220', 'latest']));
log('miner 1 balance', formatEther(miner1Final), 'proving pool escrow', formatEther(pool));
// export for the differential harness, from node 1
const exported = await rpc(node1, 'igneum_exportSegments', ['0x0', toHex(tip)]);
writeFileSync(new URL('./seq.json', import.meta.url), JSON.stringify(exported));
log('wrote seq.json with', exported.segments.length, 'segments and', exported.accounts.length, 'accounts; state root', exported.stateRoot);
writeFileSync(new URL('./smoke-results.json', import.meta.url), JSON.stringify(results, null, 1));
const fails = results.steps.filter((s) => s.fail).length;
log(`SMOKE SUMMARY: ${results.steps.length - fails} checks passed, ${fails} failed, ${((Date.now() - t0) / 1000).toFixed(0)} s, tip ${tip}`);
}
main().catch((e) => { console.error(e); process.exit(1); });