igneum/tools/reference-apps/compat/run.mjs

275 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Igneum compatibility rows (the Igneum 2.0 pin "Compatibility as a product deliverable", 8 October 2026). Every row is a
// test run against a devnet's Ethereum JSON-RPC with a funded test sender; the result file carries each row's verdict
// (passed, failed, untested) with its evidence (transaction hashes, blocks, values) and the page /compatibility renders it.
// node run.mjs [--rpc https://rpc.devnet.igneum.network] [--key ~/.config/igneum/compat-tester] [--out results.json] [--only <row-id-prefix>]
// Rows that send transactions are served "untested: no funded sender" when the key's balance is zero. Nothing here prints
// a private key. Plain Node 22 plus viem and solc (npm install in this directory).
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { createPublicClient, createWalletClient, http, defineChain, parseAbi, encodeFunctionData, encodeAbiParameters, decodeEventLog, getContractAddress, keccak256, toHex, hexToBigInt } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import solc from 'solc';
import { keccak_256 } from '@noble/hashes/sha3.js';
import { orderedTrieRoot, receiptEnvelope, bytesToHex } from '../../../site/lc/core.js';
const here = path.dirname(new URL(import.meta.url).pathname);
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : d; };
const RPC = arg('--rpc', 'https://rpc.devnet.igneum.network');
const KEY_FILE = arg('--key', path.join(process.env.HOME, '.config/igneum/compat-tester'));
const OUT = arg('--out', path.join(here, 'results.json'));
const ONLY = arg('--only', '');
const uk = () => new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit' });
// ---- the contracts under test (compiled here; the sources are the evidence) ------------------------------------
const SOURCES = {
'Token.sol': `// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Token {
string public constant name = "Compat Test Token"; string public constant symbol = "CTT"; uint8 public constant decimals = 18;
uint256 public totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(uint256 supply) { totalSupply = supply; balanceOf[msg.sender] = supply; emit Transfer(address(0), msg.sender, supply); }
function transfer(address to, uint256 v) external returns (bool) { require(balanceOf[msg.sender] >= v, "balance"); balanceOf[msg.sender] -= v; balanceOf[to] += v; emit Transfer(msg.sender, to, v); return true; }
function approve(address s, uint256 v) external returns (bool) { allowance[msg.sender][s] = v; emit Approval(msg.sender, s, v); return true; }
function transferFrom(address f, address to, uint256 v) external returns (bool) { require(allowance[f][msg.sender] >= v, "allowance"); require(balanceOf[f] >= v, "balance"); allowance[f][msg.sender] -= v; balanceOf[f] -= v; balanceOf[to] += v; emit Transfer(f, to, v); return true; }
}`,
'Probe.sol': `// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Probe {
uint256 public count;
event Context(uint256 number, uint256 timestamp, address coinbase, uint256 prevrandao, uint256 chainid, uint256 basefee, bytes32 parent, address sender, address origin, uint256 gasleft0);
function increment() external { count += 1; }
function context() external returns (uint256 number, uint256 timestamp, address coinbase, uint256 prevrandao, uint256 chainid, uint256 basefee, bytes32 parent) {
number = block.number; timestamp = block.timestamp; coinbase = block.coinbase; prevrandao = block.prevrandao; chainid = block.chainid; basefee = block.basefee; parent = blockhash(block.number - 1);
emit Context(number, timestamp, coinbase, prevrandao, chainid, basefee, parent, msg.sender, tx.origin, gasleft());
}
function view_context() external view returns (uint256, uint256, address, uint256, uint256, uint256, bytes32) { return (block.number, block.timestamp, block.coinbase, block.prevrandao, block.chainid, block.basefee, blockhash(block.number - 1)); }
function randomTwice() external view returns (uint256 a, uint256 b) { a = block.prevrandao; b = block.prevrandao; }
function fail(string calldata why) external pure { revert(why); }
function burn() external pure { uint256 x; while (true) { x += 1; } }
function create2(bytes32 salt) external returns (address a) { bytes memory code = type(Child).creationCode; assembly { a := create2(0, add(code, 32), mload(code), salt) } }
function childCode() external pure returns (bytes memory) { return type(Child).creationCode; }
}
contract Child { uint256 public born = block.number; }`,
};
function compile() {
const input = { language: 'Solidity', sources: Object.fromEntries(Object.entries(SOURCES).map(([k, v]) => [k, { content: v }])), settings: { optimizer: { enabled: true, runs: 200 }, evmVersion: 'cancun', outputSelection: { '*': { '*': ['abi', 'evm.bytecode.object'] } } } };
const out = JSON.parse(solc.compile(JSON.stringify(input)));
for (const e of out.errors || []) if (e.severity === 'error') throw new Error(e.formattedMessage);
const c = (f, n) => ({ abi: out.contracts[f][n].abi, bytecode: '0x' + out.contracts[f][n].evm.bytecode.object });
return { Token: c('Token.sol', 'Token'), Probe: c('Probe.sol', 'Probe'), Child: c('Probe.sol', 'Child'), solc: solc.version() };
}
// ---- plumbing ---------------------------------------------------------------------------------------------------
const rows = []; let deployed = {};
async function rpc(method, params = []) { const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(30000) }); const j = await r.json(); if (j.error) throw new Error(`${method}: ${j.error.message}`); return j.result; }
function row(id, item, name, verdict, detail, evidence = {}) { rows.push({ id, item, name, verdict, detail, evidence, at: new Date().toISOString() }); console.log(`${uk()} ${verdict.padEnd(8)} ${id} ${name}${detail ? ' :: ' + detail : ''}`); }
async function test(id, item, name, fn, needsSender = false) {
if (ONLY && !id.startsWith(ONLY)) return;
if (needsSender && !funded) return row(id, item, name, 'untested', 'no funded sender on this devnet tonight (the faucet refused this connection; ask again tomorrow)');
try { const r = await fn(); row(id, item, name, r && r.verdict ? r.verdict : 'passed', r && r.detail ? r.detail : '', r && r.evidence ? r.evidence : {}); }
catch (e) { row(id, item, name, 'failed', String(e.shortMessage || e.message || e).split('\n')[0].slice(0, 300)); }
}
const chainIdHex = await rpc('eth_chainId'); const CHAIN_ID = Number(chainIdHex);
const chain = defineChain({ id: CHAIN_ID, name: 'Igneum devnet', nativeCurrency: { name: 'Igneum', symbol: 'IGN', decimals: 18 }, rpcUrls: { default: { http: [RPC] } } });
const pub = createPublicClient({ chain, transport: http(RPC, { timeout: 60000 }) });
let account = null, wallet = null, funded = false;
if (existsSync(KEY_FILE)) { account = privateKeyToAccount(JSON.parse(readFileSync(KEY_FILE, 'utf8')).private_key); wallet = createWalletClient({ account, chain, transport: http(RPC, { timeout: 60000 }) }); funded = (await pub.getBalance({ address: account.address })) > 10n ** 16n; }
console.log(`${uk()} UK rpc ${RPC} chain id ${CHAIN_ID}, sender ${account ? account.address : 'none'} ${funded ? 'funded' : 'NOT funded'}`);
async function send(tx) { // sends with the node's own gas estimate (the two-dimensional fee rule), waits, returns the receipt
const gas = await pub.estimateGas({ account, ...tx });
const hash = await wallet.sendTransaction({ ...tx, gas });
const r = await pub.waitForTransactionReceipt({ hash, timeout: 180000 });
return { hash, gas, r };
}
const net = await rpc('igneum_getNodeInfo').catch(() => null);
const meta = { rpc: RPC, chain_id: CHAIN_ID, network: net && net.network, node_version: net && net.version, started: new Date().toISOString(), sender: account ? account.address : null, funded };
const C = compile(); meta.solc = C.solc;
// ---- 1 representative contracts ---------------------------------------------------------------------------------
await test('contracts.erc20.deploy', 'Representative contracts', 'an ERC-20 deploys (CREATE address as Ethereum computes it, code at the address)', async () => {
const nonce = await pub.getTransactionCount({ address: account.address });
const expected = getContractAddress({ from: account.address, nonce: BigInt(nonce) });
const { hash, r, gas } = await send({ data: encodeDeploy(C.Token, [10n ** 24n]) });
if (r.status !== 'success') throw new Error('deploy reverted');
if (r.contractAddress.toLowerCase() !== expected.toLowerCase()) throw new Error(`contractAddress ${r.contractAddress} is not the CREATE address ${expected}`);
const code = await pub.getCode({ address: r.contractAddress });
if (!code || code.length < 10) throw new Error('no code at the address');
deployed.token = r.contractAddress;
return { detail: `at ${r.contractAddress}, block ${r.blockNumber}, gas ${r.gasUsed} of ${gas} estimated`, evidence: { tx: hash, address: r.contractAddress, block: Number(r.blockNumber), gasUsed: Number(r.gasUsed), estimated: Number(gas) } };
}, true);
const erc20 = parseAbi(['function transfer(address,uint256) returns (bool)', 'function approve(address,uint256) returns (bool)', 'function transferFrom(address,address,uint256) returns (bool)', 'function balanceOf(address) view returns (uint256)', 'function allowance(address,address) view returns (uint256)', 'event Transfer(address indexed from, address indexed to, uint256 value)', 'event Approval(address indexed owner, address indexed spender, uint256 value)']);
const other = '0x000000000000000000000000000000000000c0de';
await test('contracts.erc20.transfer', 'Representative contracts', 'ERC-20 transfer: balances move, the Transfer log decodes, eth_call reads the new balance', async () => {
const { hash, r } = await send({ to: deployed.token, data: encodeFunctionData({ abi: erc20, functionName: 'transfer', args: [other, 5n * 10n ** 18n] }) });
if (r.status !== 'success') throw new Error('transfer reverted');
const log = r.logs.map(l => { try { return decodeEventLog({ abi: erc20, ...l }); } catch { return null; } }).find(x => x && x.eventName === 'Transfer');
if (!log || log.args.to.toLowerCase() !== other || log.args.value !== 5n * 10n ** 18n) throw new Error('the Transfer log does not decode to the transfer');
const bal = await pub.readContract({ address: deployed.token, abi: erc20, functionName: 'balanceOf', args: [other] });
if (bal !== 5n * 10n ** 18n) throw new Error(`balanceOf reads ${bal}`);
return { detail: `5 CTT to ${other.slice(0, 10)}, log decoded, balance read`, evidence: { tx: hash, block: Number(r.blockNumber) } };
}, true);
await test('contracts.erc20.approve', 'Representative contracts', 'ERC-20 approve then transferFrom by a spender (the sender approves itself as spender): allowance consumed, logs decode', async () => {
const a = await send({ to: deployed.token, data: encodeFunctionData({ abi: erc20, functionName: 'approve', args: [account.address, 3n * 10n ** 18n] }) });
const b = await send({ to: deployed.token, data: encodeFunctionData({ abi: erc20, functionName: 'transferFrom', args: [account.address, other, 2n * 10n ** 18n] }) });
if (a.r.status !== 'success' || b.r.status !== 'success') throw new Error('approve or transferFrom reverted');
const left = await pub.readContract({ address: deployed.token, abi: erc20, functionName: 'allowance', args: [account.address, account.address] });
if (left !== 10n ** 18n) throw new Error(`allowance left ${left}`);
return { detail: 'allowance 3 then 1 after transferFrom of 2', evidence: { approve: a.hash, transferFrom: b.hash } };
}, true);
await test('contracts.probe.deploy', 'Representative contracts', 'a probe contract deploys (counter, block context reader, revert, out-of-gas loop, CREATE2)', async () => {
const { hash, r } = await send({ data: encodeDeploy(C.Probe, []) });
if (r.status !== 'success') throw new Error('deploy reverted');
deployed.probe = r.contractAddress;
return { detail: `at ${r.contractAddress}`, evidence: { tx: hash, address: r.contractAddress, block: Number(r.blockNumber) } };
}, true);
const probe = parseAbi(['function increment()', 'function count() view returns (uint256)', 'function context() returns (uint256,uint256,address,uint256,uint256,uint256,bytes32)', 'function view_context() view returns (uint256,uint256,address,uint256,uint256,uint256,bytes32)', 'function randomTwice() view returns (uint256,uint256)', 'function fail(string)', 'function burn()', 'function create2(bytes32) returns (address)', 'function childCode() view returns (bytes)', 'event Context(uint256 number, uint256 timestamp, address coinbase, uint256 prevrandao, uint256 chainid, uint256 basefee, bytes32 parent, address sender, address origin, uint256 gasleft0)']);
await test('contracts.counter', 'Representative contracts', 'a counter increments twice and reads 2 (state persists across chain blocks)', async () => {
const a = await send({ to: deployed.probe, data: encodeFunctionData({ abi: probe, functionName: 'increment' }) });
const b = await send({ to: deployed.probe, data: encodeFunctionData({ abi: probe, functionName: 'increment' }) });
const n = await pub.readContract({ address: deployed.probe, abi: probe, functionName: 'count' });
if (n !== 2n) throw new Error(`count reads ${n}`);
return { detail: `count 2 after blocks ${a.r.blockNumber} and ${b.r.blockNumber}`, evidence: { first: a.hash, second: b.hash } };
}, true);
// ---- 2 wallet fee estimation --------------------------------------------------------------------------------------
await test('fees.quotes', 'Wallet fee estimation', 'eth_gasPrice, eth_maxPriorityFeePerGas and eth_feeHistory answer in Ethereum\'s shapes', async () => {
const gp = await rpc('eth_gasPrice'); const tip = await rpc('eth_maxPriorityFeePerGas'); const fh = await rpc('eth_feeHistory', ['0x4', 'latest', [25, 75]]);
if (!/^0x[0-9a-f]+$/.test(gp) || !/^0x[0-9a-f]+$/.test(tip)) throw new Error('quotes are not hex quantities');
if (!fh || !Array.isArray(fh.baseFeePerGas) || !Array.isArray(fh.gasUsedRatio)) throw new Error('feeHistory lacks baseFeePerGas or gasUsedRatio');
return { detail: `gasPrice ${Number(gp) / 1e9} gwei, priority ${Number(tip) / 1e9} gwei, feeHistory ${fh.baseFeePerGas.length} base fees`, evidence: { gasPrice: gp, maxPriorityFeePerGas: tip, feeHistory: fh } };
});
await test('fees.estimate.transfer', 'Wallet fee estimation', 'a plain transfer: eth_estimateGas against the receipt\'s gasUsed (the quote folds proving gas; both above Ethereum\'s 21,000)', async () => {
const est = await pub.estimateGas({ account, to: other, value: 1n });
const { hash, r } = await send({ to: other, value: 1n });
const ratio = Number(r.gasUsed) / Number(est);
return { verdict: r.status === 'success' && ratio <= 1 ? 'passed' : 'failed', detail: `estimated ${est}, used ${r.gasUsed} (${(ratio * 100).toFixed(0)}% of the estimate); Ethereum's figure is 21,000`, evidence: { tx: hash, estimated: Number(est), gasUsed: Number(r.gasUsed), ethereum: 21000 } };
}, true);
await test('fees.estimate.call', 'Wallet fee estimation', 'a contract call: eth_estimateGas against gasUsed (a wallet that quotes from the node is covered)', async () => {
const data = encodeFunctionData({ abi: probe, functionName: 'increment' });
const est = await pub.estimateGas({ account, to: deployed.probe, data });
const { hash, r } = await send({ to: deployed.probe, data });
return { verdict: r.status === 'success' && r.gasUsed <= est ? 'passed' : 'failed', detail: `estimated ${est}, used ${r.gasUsed}`, evidence: { tx: hash, estimated: Number(est), gasUsed: Number(r.gasUsed) } };
}, true);
await test('fees.local21000', 'Wallet fee estimation', 'the documented difference: a transfer sent with a local 21,000 gas limit (Ethereum\'s intrinsic cost) instead of the node\'s quote', async () => {
let outcome;
try { const hash = await wallet.sendTransaction({ to: other, value: 1n, gas: 21000n }); const r = await pub.waitForTransactionReceipt({ hash, timeout: 120000 }); outcome = { sent: true, hash, status: r.status, gasUsed: Number(r.gasUsed) }; }
catch (e) { outcome = { sent: false, refused: String(e.shortMessage || e.message).split('\n')[0].slice(0, 200) }; }
return { verdict: 'passed', detail: outcome.sent ? `the node accepted it and it ${outcome.status === 'success' ? 'succeeded' : 'FAILED'} with gasUsed ${outcome.gasUsed}: a wallet with a hard-coded 21,000 ${outcome.status === 'success' ? 'works' : 'loses the gas'}` : `the node refused it at submission: ${outcome.refused}; a wallet must quote from the node`, evidence: outcome };
}, true);
// ---- 4 failed transactions ----------------------------------------------------------------------------------------
await test('failed.revert', 'Failed transactions', 'a revert with a reason: eth_call returns the reason, the sent transaction has status 0, gas is charged, the nonce advances', async () => {
const data = encodeFunctionData({ abi: probe, functionName: 'fail', args: ['compat says no'] });
let reason = null; try { await pub.call({ account, to: deployed.probe, data }); } catch (e) { reason = String(e.shortMessage || e.message); }
if (!reason || !reason.includes('compat says no')) throw new Error(`eth_call did not surface the reason: ${reason}`);
const before = await pub.getTransactionCount({ address: account.address });
const gas = 200000n; const hash = await wallet.sendTransaction({ to: deployed.probe, data, gas });
const r = await pub.waitForTransactionReceipt({ hash, timeout: 180000 });
const after = await pub.getTransactionCount({ address: account.address });
if (r.status !== 'reverted') throw new Error(`status ${r.status}`);
if (after !== before + 1) throw new Error('nonce did not advance');
if (r.gasUsed <= 0n) throw new Error('no gas charged');
return { detail: `status 0, gasUsed ${r.gasUsed} of ${gas}, nonce ${before} to ${after}, eth_call reason surfaced`, evidence: { tx: hash, gasUsed: Number(r.gasUsed), block: Number(r.blockNumber) } };
}, true);
await test('failed.outofgas', 'Failed transactions', 'an out-of-gas call: status 0 and the whole limit charged', async () => {
const data = encodeFunctionData({ abi: probe, functionName: 'burn' });
const gas = 150000n; const hash = await wallet.sendTransaction({ to: deployed.probe, data, gas });
const r = await pub.waitForTransactionReceipt({ hash, timeout: 180000 });
if (r.status !== 'reverted') throw new Error(`status ${r.status}`);
return { detail: `status 0, gasUsed ${r.gasUsed} of the ${gas} limit`, evidence: { tx: hash, gasUsed: Number(r.gasUsed), limit: Number(gas) } };
}, true);
// ---- 5 receipts, 3 indexing ---------------------------------------------------------------------------------------
await test('receipts.fields', 'Receipts', 'a receipt carries every Ethereum field, logsBloom recomputes from its logs, contractAddress is set on creation', async () => {
const r = await rpc('eth_getTransactionReceipt', [rows.find(x => x.id === 'contracts.erc20.deploy').evidence.tx]);
for (const f of ['transactionHash', 'transactionIndex', 'blockHash', 'blockNumber', 'from', 'to', 'cumulativeGasUsed', 'gasUsed', 'contractAddress', 'logs', 'logsBloom', 'status', 'effectiveGasPrice', 'type']) if (!(f in r)) throw new Error(`receipt lacks ${f}`);
if (!r.contractAddress) throw new Error('contractAddress is null on a creation');
const bloom = bloomOf(r.logs); if (bloom !== r.logsBloom.toLowerCase()) throw new Error('logsBloom does not recompute from the logs');
return { detail: `${Object.keys(r).length} fields, bloom recomputed over ${r.logs.length} log(s), contractAddress ${r.contractAddress.slice(0, 10)}`, evidence: { tx: r.transactionHash, fields: Object.keys(r) } };
}, true);
await test('receipts.root', 'Receipts', 'the block\'s receipts rebuild its receipts trie root (Ethereum\'s layout, the node\'s encoding) equal to the header\'s receiptsRoot', async () => {
const n = rows.find(x => x.id === 'contracts.erc20.transfer'); const number = n ? '0x' + n.evidence.block.toString(16) : 'latest';
const b = await rpc('eth_getBlockByNumber', [number, false]); const rc = await rpc('eth_getBlockReceipts', [b.number]);
const root = '0x' + bytesToHex(orderedTrieRoot(rc.map(receiptEnvelope), keccak_256));
if (root !== b.receiptsRoot) throw new Error(`rebuilt ${root.slice(0, 14)}, header ${b.receiptsRoot.slice(0, 14)}`);
return { detail: `block ${Number(b.number)}, ${rc.length} receipts, root ${root.slice(0, 14)}`, evidence: { block: Number(b.number), receipts: rc.length, receiptsRoot: root } };
}, true);
await test('indexing.logs', 'Indexing', 'eth_getLogs by address, by topic and by block range returns the receipts\' logs with consistent logIndex and transactionIndex', async () => {
const a = rows.find(x => x.id === 'contracts.erc20.deploy'), t = rows.find(x => x.id === 'contracts.erc20.approve');
const from = '0x' + a.evidence.block.toString(16), to = 'latest';
const byAddr = await rpc('eth_getLogs', [{ address: deployed.token, fromBlock: from, toBlock: to }]);
const transferTopic = keccak256(toHex('Transfer(address,address,uint256)'));
const byTopic = await rpc('eth_getLogs', [{ fromBlock: from, toBlock: to, topics: [transferTopic] }]);
const mine = byTopic.filter(l => l.address.toLowerCase() === deployed.token.toLowerCase());
if (byAddr.length < 4) throw new Error(`${byAddr.length} logs by address, 4 expected (mint, transfer, approval, transferFrom)`);
if (mine.length < 3) throw new Error(`${mine.length} Transfer logs by topic, 3 expected`);
for (const l of byAddr) { const r = await rpc('eth_getTransactionReceipt', [l.transactionHash]); const same = r.logs.find(x => x.logIndex === l.logIndex); if (!same || same.data !== l.data) throw new Error('a log from eth_getLogs is not in its receipt at that logIndex'); if (r.transactionIndex !== l.transactionIndex) throw new Error('transactionIndex differs between the log and its receipt'); }
return { detail: `${byAddr.length} logs by address, ${mine.length} Transfer logs by topic, each found in its receipt`, evidence: { byAddress: byAddr.length, byTopic: mine.length, fromBlock: a.evidence.block } };
}, true);
await test('indexing.block', 'Indexing', 'eth_getBlockByNumber with full transactions, eth_getBlockReceipts and eth_getTransactionByHash agree on indices and hashes', async () => {
const n = rows.find(x => x.id === 'contracts.erc20.transfer'); const number = n ? '0x' + n.evidence.block.toString(16) : 'latest';
const b = await rpc('eth_getBlockByNumber', [number, true]); const rc = await rpc('eth_getBlockReceipts', [b.number]);
if (b.transactions.length !== rc.length) throw new Error(`${b.transactions.length} transactions, ${rc.length} receipts`);
for (let i = 0; i < rc.length; i++) { if (Number(rc[i].transactionIndex) !== i || rc[i].transactionHash !== b.transactions[i].hash) throw new Error(`index ${i} disagrees`); const t = await rpc('eth_getTransactionByHash', [rc[i].transactionHash]); if (t.blockHash !== b.hash) throw new Error('getTransactionByHash names another block'); }
return { detail: `block ${Number(b.number)}: ${rc.length} transactions, indices 0 to ${rc.length - 1} continuous`, evidence: { block: Number(b.number), count: rc.length } };
}, true);
// ---- 6 application assumptions and 7 the differences ---------------------------------------------------------------
await test('assumptions.context', 'Application assumptions', 'block.number, timestamp, chainid, basefee and blockhash(number-1) read from a contract match the block the call executed in', async () => {
const { hash, r } = await send({ to: deployed.probe, data: encodeFunctionData({ abi: probe, functionName: 'context' }) });
const ev = r.logs.map(l => { try { return decodeEventLog({ abi: probe, ...l }); } catch { return null; } }).find(x => x && x.eventName === 'Context');
const b = await rpc('eth_getBlockByNumber', ['0x' + r.blockNumber.toString(16), false]); const parent = await rpc('eth_getBlockByNumber', ['0x' + (r.blockNumber - 1n).toString(16), false]);
const checks = { number: ev.args.number === r.blockNumber, timestamp: ev.args.timestamp === hexToBigInt(b.timestamp), chainid: Number(ev.args.chainid) === CHAIN_ID, basefee: ev.args.basefee === hexToBigInt(b.baseFeePerGas), parent: ev.args.parent === parent.hash, sender: ev.args.sender.toLowerCase() === account.address.toLowerCase(), origin: ev.args.origin.toLowerCase() === account.address.toLowerCase() };
const bad = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
deployed.contextTx = { hash, block: Number(r.blockNumber), coinbase: ev.args.coinbase, prevrandao: ev.args.prevrandao.toString(16) };
if (bad.length) throw new Error('mismatch: ' + bad.join(', '));
return { detail: `block ${r.blockNumber}: number, timestamp, chainid ${CHAIN_ID}, basefee, blockhash(n-1) = parentHash, msg.sender and tx.origin all match`, evidence: { tx: hash, block: Number(r.blockNumber) } };
}, true);
await test('assumptions.create2', 'Application assumptions', 'CREATE2 lands at the address Ethereum computes (keccak(0xff, deployer, salt, keccak(initcode)))', async () => {
const salt = '0x' + '42'.repeat(32);
const code = await pub.readContract({ address: deployed.probe, abi: probe, functionName: 'childCode' });
const expected = getContractAddress({ opcode: 'CREATE2', from: deployed.probe, salt, bytecode: code });
const { hash } = await send({ to: deployed.probe, data: encodeFunctionData({ abi: probe, functionName: 'create2', args: [salt] }) });
const got = await pub.getCode({ address: expected });
if (!got || got.length < 10) throw new Error(`no code at the computed CREATE2 address ${expected}`);
return { detail: `child at ${expected}`, evidence: { tx: hash, address: expected } };
}, true);
await test('difference.coinbase', 'Differences: block context', 'block.coinbase is the miner of the DAG block that first included the transaction, not one miner per chain block (measured: coinbase against the block\'s igneum.mergeset)', async () => {
const c = deployed.contextTx; const b = await rpc('eth_getBlockByNumber', ['0x' + c.block.toString(16), false]);
const t = await rpc('eth_getTransactionByHash', [c.hash]);
const includingMiner = t.igneum && t.igneum.includingMiner;
const miners = (b.igneum && b.igneum.mergeset || []).map(m => m.miner.toLowerCase());
const same = includingMiner && c.coinbase.toLowerCase() === includingMiner.toLowerCase();
return { verdict: same ? 'passed' : 'failed', detail: `coinbase ${c.coinbase.slice(0, 10)} = the including block's miner ${String(includingMiner).slice(0, 10)}; the chain block merged ${miners.length} block(s) with ${new Set(miners).size} distinct miner(s), chain block miner ${String(b.miner).slice(0, 10)}`, evidence: { tx: c.hash, coinbase: c.coinbase, includingMiner, mergesetMiners: miners, chainBlockMiner: b.miner } };
}, true);
await test('difference.randomness', 'Differences: randomness', 'block.prevrandao: its value is derived from the chain\'s epoch seed (not Ethereum\'s beacon RANDAO), equal for two reads in one call and across the block', async () => {
const c = deployed.contextTx; const b = await rpc('eth_getBlockByNumber', ['0x' + c.block.toString(16), false]);
const [a1, a2] = await pub.readContract({ address: deployed.probe, abi: probe, functionName: 'randomTwice' });
const nodeValue = b.igneum && (b.igneum.prevrandao || b.igneum.epochSeed);
return { verdict: a1 === a2 ? 'passed' : 'failed', detail: `two reads in one call equal; the block's value 0x${c.prevrandao.slice(0, 12)}…; the node reports epochSeed ${String(b.igneum && b.igneum.epochSeed).slice(0, 14)}… for the block (the source: the chain's epoch seed, fixed per epoch, so prevrandao is NOT fresh per block and must not seed a lottery)`, evidence: { tx: c.hash, prevrandao: '0x' + c.prevrandao, epochSeed: b.igneum && b.igneum.epochSeed, nodeField: nodeValue } };
}, true);
await test('difference.fees', 'Differences: two-dimensional fees', 'a receipt carries pgasUsed, provingBaseFeePerGas, burnedProvingFee and burnedExecutionBaseFee; effectiveGasPrice covers both dimensions', async () => {
const r = await rpc('eth_getTransactionReceipt', [rows.find(x => x.id === 'contracts.erc20.transfer').evidence.tx]);
const ig = r.igneum || {};
for (const f of ['pgasUsed', 'provingBaseFeePerGas', 'burnedProvingFee', 'burnedExecutionBaseFee', 'minerTip']) if (!(f in ig)) throw new Error(`receipt.igneum lacks ${f}`);
const paid = BigInt(r.gasUsed) * BigInt(r.effectiveGasPrice);
const parts = BigInt(ig.burnedProvingFee) + BigInt(ig.burnedExecutionBaseFee) + BigInt(ig.minerTip) + (ig.developerShares || []).reduce((a, d) => a + BigInt(d.wei), 0n);
return { verdict: 'passed', detail: `gasUsed ${Number(r.gasUsed)}, pgasUsed ${Number(ig.pgasUsed)}, proving base fee ${Number(ig.provingBaseFeePerGas) / 1e9} gwei per pgas; paid ${paid} wei, of which proving burn ${BigInt(ig.burnedProvingFee)}, execution burn ${BigInt(ig.burnedExecutionBaseFee)}, tip ${BigInt(ig.minerTip)} (parts sum ${parts === paid ? 'equals' : 'differs from'} gasUsed × effectiveGasPrice)`, evidence: { tx: r.transactionHash, gasUsed: Number(r.gasUsed), pgasUsed: Number(ig.pgasUsed), effectiveGasPrice: r.effectiveGasPrice, igneum: ig, parts_equal_paid: parts === paid } };
}, true);
// ---- helpers ----------------------------------------------------------------------------------------------------
function encodeDeploy(c, args) { return args.length ? c.bytecode + encodeConstructorArgs(c.abi, args) : c.bytecode; }
function encodeConstructorArgs(abi, args) { const ctor = abi.find(x => x.type === 'constructor'); if (!ctor) return ''; return encodeAbiParameters(ctor.inputs, args).slice(2); }
function bloomOf(logs) {
const bloom = new Uint8Array(256);
const add = bytes => { const h = keccak_256(bytes); for (let i = 0; i < 6; i += 2) { const bit = ((h[i] << 8) | h[i + 1]) & 2047; bloom[255 - (bit >> 3)] |= 1 << (bit & 7); } };
const hb = s => Uint8Array.from(Buffer.from(s.slice(2), 'hex'));
for (const l of logs) { add(hb(l.address)); for (const t of l.topics) add(hb(t)); }
return '0x' + Buffer.from(bloom).toString('hex');
}
meta.finished = new Date().toISOString();
const summary = { passed: rows.filter(r => r.verdict === 'passed').length, failed: rows.filter(r => r.verdict === 'failed').length, untested: rows.filter(r => r.verdict === 'untested').length };
writeFileSync(OUT, JSON.stringify({ meta, summary, deployed, rows }, null, 1) + '\n');
console.log(`${uk()} UK ${summary.passed} passed, ${summary.failed} failed, ${summary.untested} untested; written ${OUT}`);