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>
252 lines
18 KiB
JavaScript
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); });
|