diff --git a/docs/bench-log.md b/docs/bench-log.md index 1e9cdce82..e70f28cec 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -277,3 +277,15 @@ Observer and site: `tools/observer/observer.mjs` with `LIVE_TABLE_PREFIX=fintest Live devnet follower (`igneumd` v2 on gRPC 26690, p2p 26691, JSON 28690, `--connect=127.0.0.1:26611`, never mining): IBD of 5,254 blocks from node 1 in under a second, kept in sync (5,418 blocks at 21:53, 0.70 blocks/s on the live chain), determined checkpoints 1 to 302 within 1 s of IBD; weights at checkpoint 296 (DAA 8,935): 16 keys, 14 above dust, total 7,146 blocks (eight RTX 5090 identities at 862 to 936 blocks, six at 4 to 10), 0 revealed keys, 0 votes, participation 0, 0 locks, `finality_active` false, exactly as expected while the Windows miners run the pre-v2 binary. RSS 1.1 GB. It only ever receives from node 1 (its protocol version 12 against node 1's 11 means no finality messages in either direction). Open: one stall of all three test nodes at 21:48:43 BST in the first run (stripped binary, right after the follower, the equivocating miner and the test observer started): all three logs stop in the same second, every RPC times out, CPU 0%, node 1 of the live devnet unaffected; not reproduced in 28 minutes of the same scenario on the symbolized build (lock 40 to 93 without a pause, including the equivocation and the partition). The first run's self-deadlock (`compute_weights` taking the state lock its callers hold) was found and fixed before that stall, and `Router::enqueue` is a non-blocking try_send, so the gossip pump cannot deadlock across nodes; cause unknown. Also open: C3 validity rule and F3 pruning bound not enforced; d = 20 chosen without the reorg-depth distribution; the weight walk is O(window) per checkpoint; one certificate per index per node means a certificate often names fewer signers than the votes that exist. Not demonstrated: locks on the live devnet (its miners do not vote yet), the Windows binaries on Windows, a certificate carried into a block and verified by a cold node that missed the gossip (the follower had no votes to receive), the 2-hour presence window at full length (the run was 53 minutes). + +## 3 October 2026, execution layer devnet v3: revm over the selected chain, 3-node simnet, viem smoke test (execution-engineer) + +Machine: the same Apple M5 Max (18 cores), shared with two other agents' builds (load 15 to 55). Branch `execution-layer` in `vendor/igneum-node-exec`, worktree of `vendor/igneum-node` from `d62708a8`; revm 43.0.3, alloy-primitives 1.7.3, alloy-consensus 2.5.0, alloy-trie 0.9.8, axum 0.8.9; viem 2.57.2, solc 0.8.37 (tools/evm-smoke). Release build `CARGO_TARGET_DIR=target cargo build --release -p kaspad -p igneum-miner --features igneum-pow`: first full build with the new crates about 20 min under `nice -n 10 -j 10` on the loaded machine; incremental igneumd rebuilds 35 s to 4 min. +Test network: 3 `igneumd --simnet` nodes (devnet block rate and depths, proof of work skipped, chain id 4463) on gRPC 26700/26710/26720, p2p 26701/26711/26721, eth RPC 26790/26791/26792, appdirs /tmp/igneum-exec-test; 3 single-thread `igneum-miner --engine stub --hold-ms 2500` (exponential hold, mean 2.5 s per miner). Rate: 130 blocks in 120 s = 1.08 blocks/s; 68 chain blocks; selected-chain reorgs 22 in 120 s, depth 1 (17) and 2 (5), identical on the 3 nodes. Earlier run with a fixed 900 ms hold: 3 blocks/s in lockstep rounds and 80 to 92 reorgs in 60 s with flips 45 to 69 deep (equal-work chains kept alive by the hash tie-break); every flip was unwound correctly (state roots identical on 3 nodes at block 32 after 65-deep flips), the fix is Poisson pacing in the miner. +Smoke test (`node tools/evm-smoke/smoke.mjs`, stock viem paths): 87 checks passed, 0 failed, 36 s wall, tip at chain block 78. eth_chainId 0x116f, net_version 4463. Miner 1 (EVM address = low 20 bytes of its vote key hash) held 91.28 IGN at chain block 53 from 80% subsidy shares of 36.59 IGN per blue block (3,168,808,781 sompi x 1e10 x 0.8, launch-ramp day 0 is not applied on simnet's genesis timestamp); proving pool escrow 92.55 IGN at the end. Funding: 3 transfers of 5 IGN, eth_estimateGas 25,380 (21,000 plus the pgas fold and the 15% margin), all status 1, balances exact. +50 transfers between 3 accounts (each sender's transactions to one node, no p2p relay of EVM transactions): all 50 executed in 16 s wall across chain blocks 58 (17), 61 (20), 62 (13); 57 executed transactions in 7 chain blocks over the run, max 20 per chain block; 19 skipped copies (the same miner re-including transactions handed out before its earlier template landed, every one skipped by the nonce rule with no fee and no receipt). Balances of the 3 accounts matched the receipt accounting to the wei (value plus gas_used x effectiveGasPrice plus burnedProvingFee). +Transfer receipt: gasUsed 21,000, pgasUsed 200, effectiveGasPrice 2 gwei (base 1 gwei, tip 1 gwei), burnedProvingFee 200 gwei (200 pgas x 1 gwei), minerTip 16,800 gwei (80% of 21,000 gwei), developerShares [burned 4,200 gwei] (unregistered, 20%). Contract call `increment(5)`: gasUsed 45,354, pgasUsed 1,288, minerTip 36,283.2 gwei (80%), developer share 9,070.8 gwei (20%) credited to the payee the constructor registered (balance delta equal). Deployment via viem `deployContract`: gasUsed 185,948, pgasUsed 1,438, registry `creatorOf` = deployer and `payeeOf` = constructor argument (executor CREATE rule plus `register`). `eth_estimateGas` reports the revert of `increment(0)`; `eth_call hashLoop(50)` returns; `eth_estimateGas hashLoop(200)` = 98,900 with the fold; `eth_getLogs` finds the event. Measured pgas/gas: 0.0095 transfer, 0.028 storage write with event, 0.0077 deployment, 0.0099 averaged (prototype table, below the design's 0.1 to 10 band as expected before calibration). +Duplicates in parallel blocks: one identical copy sent to nodes 1 and 2 was included twice (chain blocks 63 and 64, blocks c09c9329... and 3f401bd2...), executed once, the second skipped `NonceTooLow { expected: 18, got: 17 }`; a conflicting same-nonce pair (different values, one copy per node) executed exactly once (B1), the loser never reached a block because its node's pool dropped it once the nonce had passed (`igneum_getTransactionStatus`: includedIn [], executed false). +Execution time per chain block (node 1, `igneum.executionMicros`, includes the full-recompute state root): 50, 71, 93, 57, 41, 46, 50 us for the 7 chain blocks with 3, 17, 20, 13, 2, 1, 1 transactions; 71 empty chain blocks averaged 11 us; the same chain block on the 3 nodes: 50 / 192 / 85 us (block 56) and 50 / 88 / 46 us (block 78). State roots as outputs: genesis (registry only) 7e37a9fb19b154d32daf5bf30a50d339a75029fbc9eec9ea20e95439dba5a311; chain block 56 (3 funding transfers) 68cacfd393b00ead784a69b10d57a3e2dd57858029df107b529487a49f393b50; chain block 78 5b18b3a58f6c1d21b22caad4a1dbd9ee8a6394a02db2220255e556a9d4f90878; identical hash and root on all 3 nodes at heights 0, 56, 74 and 78; the root advanced at every block with transactions. Base fees stayed at the 1 gwei floor (segments far below the 15 M gas target). +Differential (`igneum-exec-diff seq.json`, plain revm without inspector, pgas or split, balances adjusted by the exported Igneum-only flows): segments 0 to 78, 57 executed transactions compared (status, gas used, logs), 19 skipped copies confirmed rejected by plain revm at their positions, 10 accounts compared (balance, nonce, code hash), 0 mismatches. `cargo test -p igneum-evm-types`: 3 passed. +Not done: on-disk state and incremental trie (state rebuilt from genesis at start), header fields `utxo_commitment` and `accepted_id_merkle_root` kept (proofs_root is an RPC placeholder), body `miner` field and `proofs` section, pgas calibration, proving layer, eager virtual execution, eth_getProof/subscribe/debug, EVM transaction relay between nodes, the finality merge (plan in the design document, section 10.4). Test network stopped at the end of the run. diff --git a/docs/design/execution-layer.md b/docs/design/execution-layer.md index cc9c71825..e8b6f1262 100644 --- a/docs/design/execution-layer.md +++ b/docs/design/execution-layer.md @@ -1,6 +1,6 @@ # Igneum execution layer: design -Design version 0.1, 3 October 2026. Status: Designed. Nothing in this document is implemented or measured unless the sentence says so. +Design version 0.1, 3 October 2026. Status: Designed. Nothing in this document is implemented or measured unless the sentence says so. Section 10 (added later on 3 October 2026) records what devnet v3 implements and measures. Owner: execution engineer. Reviewers: consensus engineer (sections 1 and 3), cryptographer (sections 5 and 6), miner-community lead (section 4). @@ -415,3 +415,53 @@ Devnet v1 is phase 3 (Feb to Mar 2027, 20 nodes) with the stub `ProofSystem` of | A9 | Developer share lands at the registered payee for a factory-deployed contract; self-dealing transactions are excluded from the leaderboard | Script | Sources read for this document: `vendor/rusty-kaspa` at `01b532e8` (files cited inline), `docs/fork-map.md`, `docs/fud-ledger.md` entries F13, P1 to P10, E3, E4, C4, C5 and overclaims 25 to 27 and 35 to 38, `docs/spec/00-overview.md`, the Igneum design document (sections "Architecture", "Security model", "Risks and their solutions", "Finality rule, version 2", "What the hostile review changed", "Build plan"), `site/journey.json`, Conflux eSpace EVM compatibility and consensus design pages, Kasplex L2 docs, 0xPolygon/zkevm-rom `docs/opcode-cost-zk-counters.md`, ZKsync fee-model docs, Succinct's SP1 launch and 8/6/24 benchmark posts, chainid.network. Figures from the web sources are approximate unless the page states the number. + +## 10. Implementation notes: devnet v3 (3 October 2026) + +Branch `execution-layer` in `vendor/igneum-node-exec` (a git worktree of `vendor/igneum-node`, forked from `d62708a8`, the rename commit). File list and merge notes: `docs/fork-divergence.md`, "Execution layer". Numbers: `docs/bench-log.md`, the devnet v3 entry. Everything below is Implemented on a 3-node test network unless marked prototype. + +### 10.1 What runs, decision by decision + +| Decision | Status | Where | Note | +|---|---|---|---| +| D1 block body | Implemented, partial | `consensus/core/src/block.rs`, `evm.rs`, `body_validation_in_isolation.rs` | `Block.evm_transactions: Vec>` (raw EIP-2718 types 0, 1, 2). Body validation rejects any UTXO transaction besides the coinbase (`UtxoTransactionsRetired`). The coinbase stays a UTXO transaction: Kaspa's UTXO pipeline (coinbase manager, `utxo_commitment`, acceptance data) still runs underneath and is what the EVM reward rule reads its mergeset order from. Rewards on the EVM side are applied by rule, not by a transaction (below). No `proofs` section and no 20-byte `miner` field in the body yet | +| D2 canonical sequence | Implemented | `igneum/exec/src/service.rs`, `execute_chain_block` | Segment(C) = `get_block_acceptance_data(C)` minus its first entry (the selected parent), then C. Acceptance data is written by `calculate_utxo_state` in `consensus_ordered_mergeset` order (`consensus/src/pipeline/virtual_processor/utxo_validation.rs:106 to 169` in this fork; `consensus/src/model/stores/ghostdag.rs:116 to 136` and `:175 to 180` for the ordering), so the executor reuses Kaspa's ordering primitive without a second implementation. Reds are in that order and are executed; only their reward is withheld | +| D3 duplicate inclusion | Implemented | `executor.rs` | The second copy fails revm's nonce check at its position (`NonceTooLow`), gets no receipt and no fee; the intrinsic pgas (200, prototype) is charged to the including block's proving budget. Measured: an identical copy sent to two nodes landed in parallel blocks; first copy executed, second skipped with `NonceTooLow { expected: 18, got: 17 }` | +| D4 nonces | Implemented | `igneum/evm-types/src/lib.rs`, `validate_body` | Per sender sorted and contiguous in a body, checked without state; a violation is a body fault. Mempool: 64 queued per sender (design said 16; raised so one node can line up several blocks of one sender), nonce gap 16 | +| D5 two fault classes | Implemented | `igneum-evm-types` (state-free), `executor.rs` (state-dependent) | State-free: decoding, signature, chain id, type, intrinsic gas, initcode size, priority above max fee, duplicate hash, nonce order, sum of gas limits above `B_e`, body above 1 MiB. State-dependent: revm's `InvalidTransaction` mapped to `SkipReason`, plus the block proving budget. Skipped copies are listed per chain block and per transaction (`igneum_getSegment`, `igneum_getTransactionStatus`) | +| D6 revm and the MPT | Implemented, devnet scope | `state.rs` | revm 43, Cancun, in-memory `CacheDB`; the state root is computed after every segment over the whole account set with alloy-trie (keccak-keyed MPT, `TrieAccount` layout) and exposed as `stateRoot` in RPC blocks. It is an output: no header carries it. Not reth's database, not incremental, not on disk (below) | +| D7 header | Partial | `consensus/core/src/merkle.rs` | `hash_merkle_root` is REUSED as the body commitment: leaves are the UTXO transaction hashes (the coinbase) followed by keccak256 of each raw EVM transaction, so with no EVM transactions the root is Kaspa's and no genesis hash moved. `utxo_commitment` and `accepted_id_merkle_root` are NOT dropped: the UTXO pipeline still validates both. `proofs_root` exists only as a zero placeholder in `eth_getBlockByNumber` (`igneum.proofsRoot`), not in the header | +| D8 semantics | Implemented | `service.rs`, `executor.rs` | `block.number` = selected-chain height (contiguous; verified across reorgs); timestamp `max(parent, header_ms div 1000)`; `blockhash` from a 256-entry ring of chain block hashes; PREVRANDAO = `keccak256(epoch_seed ‖ number_be64)` with the devnet epoch seed (the last chain block below the epoch's start DAA score, genesis for epoch 0, the PoW rule of this fork); coinbase = the including block's miner; Cancun opcodes; BLOBBASEFEE 1 and no blobs (revm's `BlobExcessGasAndPrice::new(0, ..)`); chain id 4463 (simnet shares it) | +| D9 two gas dimensions | Implemented, prototype table | `pgas.rs`, `executor.rs` | pgas metered per opcode and per precompile by a revm inspector (`IgneumInspector::step`, `call`), intrinsic 200 per included transaction. One gas limit; the inspector halts the frame as out of gas when `running_gas x (f_e + tip) + pgas x f_p` would exceed `gas_limit x max_fee_per_gas` (the over-budget path charges the budget). Both base fees burned (the execution base fee by revm's own accounting, the proving charge taken from the sender after execution); EIP-1559 step per chain block for each dimension toward half of `B_e` = `B_p` = 30 M, floor 1 gwei. `eth_gasPrice` quotes `f_e + f_p x ratio + tip`, `eth_estimateGas` folds `pgas x f_p / f_e` into the limit. The table magnitudes are placeholders: measured `pgas / gas` is 0.0095 for a transfer, 0.028 for a storage write with an event, 0.0077 for a deployment, below the design's 0.1 to 10 band, as expected before the R1 calibration | +| D10 developer attribution | Implemented | `pgas.rs` (per-frame own gas, CREATE rule), `registry.rs`, `contracts/DeveloperRegistry.sol` | Registry at `0x0000000000000000000000000000000000000210` in genesis state (slot 0 `payee`, slot 1 `creator`). The inspector writes `creator[new]` and the inherited `payee[new]` through the journal on every successful CREATE and CREATE2; `register(account, payee)` is allowed for the account itself or its recorded creator. 20% of the tip is split by own execution gas per frame to the payee of the frame's code address (callee for CALL and STATICCALL, code address for DELEGATECALL and CALLCODE), unregistered parts and precompile frames burned. Measured: a contract that registers a payee in its constructor sent 20% of the tip of `increment(5)` to that payee; a plain transfer burned its 20% | +| 8.2 RPC | Implemented | `rpc.rs` | `eth_chainId, blockNumber, gasPrice, maxPriorityFeePerGas, feeHistory, getBalance, getTransactionCount, getCode, getStorageAt, getBlockByNumber, getBlockByHash, getBlockTransactionCountByNumber, getBlockReceipts, getTransactionByHash, getTransactionReceipt, getTransactionByBlockNumberAndIndex, getLogs, sendRawTransaction, call, estimateGas, syncing, accounts, net_version, net_listening, web3_clientVersion`; `igneum_getTransactionStatus, igneum_getSegment, igneum_getBudgets, igneum_exportSegments`. HTTP JSON-RPC 2.0 with batches, port 26790 on devnet and simnet. viem 2.57 drove the whole smoke test through the stock paths (`deployContract`, `writeContract`, `readContract`, fee estimation). Missing: `eth_getProof`, `eth_subscribe`, `debug_*`, `trace_*`, `eth_getUncle*` | +| 8.5 differential | Implemented, first row only in spirit | `igneum/exec/src/bin/diff.rs` | `igneum-exec-diff` replays the node's exported sequence through plain revm (no inspector, no pgas, no split) and compares status, gas used and logs per transaction, confirms every skipped copy is rejected by plain revm at its position, and compares every account's balance after adding back the Igneum-only flows the export records (proving charges, developer shares). 0 mismatches over 79 segments, 57 executed, 19 skipped, 10 accounts | + +### 10.2 Devnet rules fixed here (not in the design text) + +| Rule | Value | Why | +|---|---|---| +| Miner address | Low 20 bytes of the header's `vote_key_hash` (`evm::miner_evm_address`) | The body has no `miner` field yet. A miner that wants to spend its rewards passes `--vote-key-hash 0x000000000000000000000000
` to `igneum-miner`. After the finality branch merges, `vote_key_hash` is the hash of a BLS key, so this rule must give way to the body's `miner` field (10.4) | +| Units | 1 sompi = 1e10 wei; 1 IGN = 1e18 wei | Subsidies come from `igneum::block_subsidy` in 8-decimal sompi; the EVM is 18-decimal. The open "8 or 18 decimals" decision is unchanged; this is the fixed scaling at the bridge named there | +| Rewards | 80% of every blue block's subsidy to its miner, 20% to the proving pool escrow `0x...0220`, both credited in the segment that merges the block; reds unpaid | Design 4.4, with the pool held in a keyless account until proof records exist | +| Simnet | Devnet block rate and depths (1 BPS, k 18, mergeset 180, merge depth 3,600) with proof of work skipped; chain id 4463 shared with the devnet | A CPU test network of the devnet DAG shape | +| Mempool hand-out | A transaction handed to a template is not offered again for 4 s unless a chain block skipped it; a transaction skipped twice is dropped | Kaspa removes a block's transactions on block-added; the cooldown is the stand-in until the executor listens to block-added | +| Reorgs | Post-segment states for the last 64 chain blocks; deeper reorgs replay from genesis | Observed depth on the test network: 1 to 3 with Poisson-paced miners. A fixed per-template hold had made the three stub miners mine in lockstep rounds, and with equal work per block the GHOSTDAG hash tie-break then kept two equal-work chains alive from genesis (flips 48 deep every few seconds); `igneum-miner --hold-ms` is exponential now | +| Block tags | `pending`, `safe` and `finalized` all resolve to the executed tip | The virtual's segment is not executed eagerly and no certified checkpoint exists on this branch; the RPC does not pretend otherwise | + +### 10.3 What is missing from the design, in order of weight + +1. Persistence and the trie. State is in memory, rebuilt from consensus data at start (every segment replays from genesis), and the state root is a full recompute per segment. The design wants reth's database layout with incremental root maintenance; `eth_getProof` depends on it. Devnet v1 needs it for a 24-hour run with real state. +2. The header (D7). `utxo_commitment` and `accepted_id_merkle_root` still exist and the UTXO pipeline still validates them; `proofs_root` is an RPC placeholder. Removing the UTXO layer (coinbase manager, UTXO set, multiset, KIP-21 sequencing commitment) is the pass that makes the header "a pure function of the DAG and the body". Until then the body's only UTXO transaction is the coinbase, and the EVM reward rule runs beside it. +3. The body's `miner` field and `proofs` section (D1, 5.4). The miner address rule above is a stand-in. +4. The pgas table (4.2) is a prototype: shapes from the design, magnitudes unmeasured. R1 calibration in SP1 replaces every entry. +5. Proving: no `ProofSystem`, no shard plan, no sortition, no proof records, no veto, no pool credit (sections 5 and 6). The pool share accrues in an escrow account. +6. The virtual's segment is not executed eagerly (design 1.2 "about one second after inclusion"); the executor runs about one chain block behind the sink. `pending` tags resolve to the executed tip. +7. `eth_subscribe`, `debug_traceTransaction`, `trace_block`, `eth_getProof`, `eth_getUncle*`, `IgneumInfo`: not implemented. +8. The chain follower polls `get_virtual_chain_from_block` every 100 ms instead of subscribing to virtual-chain-changed notifications. +9. Mempool: no p2p relay of EVM transactions between nodes (each node's pool is what its RPC received), no eviction by age, no fee-based replacement beyond the 10% rule. +10. Differential rows 1 (ethereum/tests), 3 (independent linearizer), 4 (Python oracle), 5 and 6 are not built; the harness here is the balance and receipt comparison of the acceptance criteria. + +### 10.4 Merge plan with the finality branch + +The finality work (`vendor/igneum-node` master, commits `c6d47547` to `2a00ff55`) and this branch fork from the same commit. Files both touch, all with additions only on both sides: `Cargo.toml`, `Cargo.lock`, `components/consensusmanager/src/session.rs`, `consensus/core/Cargo.toml`, `consensus/core/src/api/mod.rs`, `consensus/core/src/config/params.rs`, `consensus/core/src/lib.rs`, `consensus/src/consensus/mod.rs`, `consensus/src/pipeline/body_processor/processor.rs`, `igneum/miner/src/main.rs`, `kaspad/src/args.rs`, `protocol/p2p/proto/p2p.proto`, `rpc/grpc/core/proto/rpc.proto`, `rpc/service/src/service.rs`. Order: rebase `execution-layer` onto master (the finality branch is larger, 52 files). Expected conflicts are line-local; two need a decision: `igneum/miner/src/main.rs` (the finality branch replaces the vote-key label with a BLS identity, so `--vote-key-hash` goes and the EVM payout address must come from a body `miner` field or a `--evm-address` the template builder writes into the body), and `p2p.proto` (the finality branch bumps the protocol version to 12 and adds messages; the `evmTransactions` field numbers 3 and 2 on the block messages do not collide). After the rebase, re-run `cargo test -p igneum-evm-types`, the 3-node smoke test (`tools/evm-smoke`) and `igneum-exec-diff`. diff --git a/tools/evm-smoke/.gitignore b/tools/evm-smoke/.gitignore new file mode 100644 index 000000000..d2637bc35 --- /dev/null +++ b/tools/evm-smoke/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +seq.json +smoke-results.json diff --git a/tools/evm-smoke/Counter.sol b/tools/evm-smoke/Counter.sol new file mode 100644 index 000000000..60c436647 --- /dev/null +++ b/tools/evm-smoke/Counter.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: ISC +pragma solidity ^0.8.20; + +/// Smoke-test contract: a counter with an event, a view, a revert path and a registry call from its constructor. +interface IDeveloperRegistry { function register(address account, address payee) external; } + +contract Counter { + uint256 public count; + event Incremented(address indexed by, uint256 newCount); + + constructor(address registry, address payee) { + if (registry != address(0)) { + IDeveloperRegistry(registry).register(address(this), payee); + } + } + + function increment(uint256 by) external returns (uint256) { + require(by > 0, "Counter: zero"); + count += by; + emit Incremented(msg.sender, count); + return count; + } + + function hashLoop(uint256 rounds) external pure returns (bytes32 h) { + for (uint256 i = 0; i < rounds; i++) { + h = keccak256(abi.encodePacked(h, i)); + } + } +} diff --git a/tools/evm-smoke/compile.mjs b/tools/evm-smoke/compile.mjs new file mode 100644 index 000000000..1bf84eeb0 --- /dev/null +++ b/tools/evm-smoke/compile.mjs @@ -0,0 +1,28 @@ +// Compiles igneum/exec/contracts/DeveloperRegistry.sol and the smoke-test Counter contract with solc-js and writes +// the runtime bytecode next to the source (the execution crate embeds it with include_str!). +import solc from 'solc'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const registrySrc = path.resolve(here, '../../vendor/igneum-node-exec/igneum/exec/contracts/DeveloperRegistry.sol'); +const sources = { + 'DeveloperRegistry.sol': { content: readFileSync(registrySrc, 'utf8') }, + 'Counter.sol': { content: readFileSync(path.join(here, 'Counter.sol'), 'utf8') }, +}; +const input = { + language: 'Solidity', + sources, + settings: { optimizer: { enabled: true, runs: 200 }, evmVersion: 'cancun', outputSelection: { '*': { '*': ['abi', 'evm.bytecode.object', 'evm.deployedBytecode.object'] } } }, +}; +const out = JSON.parse(solc.compile(JSON.stringify(input))); +for (const e of out.errors ?? []) { console.error(e.formattedMessage); if (e.severity === 'error') process.exit(1); } +const reg = out.contracts['DeveloperRegistry.sol'].DeveloperRegistry; +const header = `// solc ${solc.version()} optimizer 200 runs evmVersion cancun; source DeveloperRegistry.sol\n`; +writeFileSync(path.join(path.dirname(registrySrc), 'DeveloperRegistry.bin-runtime'), reg.evm.deployedBytecode.object + '\n'); +writeFileSync(path.join(path.dirname(registrySrc), 'DeveloperRegistry.abi.json'), JSON.stringify(reg.abi, null, 1) + '\n'); +writeFileSync(path.join(path.dirname(registrySrc), 'DeveloperRegistry.compiler.txt'), header); +const counter = out.contracts['Counter.sol'].Counter; +writeFileSync(path.join(here, 'counter.json'), JSON.stringify({ abi: counter.abi, bytecode: '0x' + counter.evm.bytecode.object }, null, 1) + '\n'); +console.log('solc', solc.version(), 'registry runtime bytes', reg.evm.deployedBytecode.object.length / 2, 'counter creation bytes', counter.evm.bytecode.object.length / 2); diff --git a/tools/evm-smoke/counter.json b/tools/evm-smoke/counter.json new file mode 100644 index 000000000..295af1751 --- /dev/null +++ b/tools/evm-smoke/counter.json @@ -0,0 +1,91 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "registry", + "type": "address" + }, + { + "internalType": "address", + "name": "payee", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "by", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newCount", + "type": "uint256" + } + ], + "name": "Incremented", + "type": "event" + }, + { + "inputs": [], + "name": "count", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "rounds", + "type": "uint256" + } + ], + "name": "hashLoop", + "outputs": [ + { + "internalType": "bytes32", + "name": "h", + "type": "bytes32" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "by", + "type": "uint256" + } + ], + "name": "increment", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x608060405234801561000f575f5ffd5b506040516102cd3803806102cd83398101604081905261002e916100bc565b6001600160a01b0382161561009a57604051632a99dcd560e21b81523060048201526001600160a01b03828116602483015283169063aa677354906044015f604051808303815f87803b158015610083575f5ffd5b505af1158015610095573d5f5f3e3d5ffd5b505050505b50506100ed565b80516001600160a01b03811681146100b7575f5ffd5b919050565b5f5f604083850312156100cd575f5ffd5b6100d6836100a1565b91506100e4602084016100a1565b90509250929050565b6101d3806100fa5f395ff3fe608060405234801561000f575f5ffd5b506004361061003f575f3560e01c806306661abd14610043578063671513191461005d5780637cf5dab014610070575b5f5ffd5b61004b5f5481565b60405190815260200160405180910390f35b61004b61006b366004610161565b610083565b61004b61007e366004610161565b6100ca565b5f805b828110156100c457604080516020810184905290810182905260600160408051601f1981840301815291905280516020909101209150600101610086565b50919050565b5f5f821161010e5760405162461bcd60e51b815260206004820152600d60248201526c436f756e7465723a207a65726f60981b604482015260640160405180910390fd5b815f5f82825461011e9190610178565b90915550505f5460405190815233907f38ac789ed44572701765277c4d0970f2db1c1a571ed39e84358095ae4eaa54209060200160405180910390a250505f5490565b5f60208284031215610171575f5ffd5b5035919050565b8082018082111561019757634e487b7160e01b5f52601160045260245ffd5b9291505056fea2646970667358221220550fbe02c34bbf880b632f2e9e937266b869dc777da392b2cfceaf158da2014364736f6c63430008250033" +} diff --git a/tools/evm-smoke/package-lock.json b/tools/evm-smoke/package-lock.json new file mode 100644 index 000000000..4dc25c814 --- /dev/null +++ b/tools/evm-smoke/package-lock.json @@ -0,0 +1,308 @@ +{ + "name": "igneum-evm-smoke", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "igneum-evm-smoke", + "version": "0.1.0", + "dependencies": { + "solc": "^0.8.28", + "viem": "^2.21.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", + "license": "MIT" + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/ox": { + "version": "0.14.45", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.45.tgz", + "integrity": "sha512-jpsQ+p0JZh9mKCxxzxz0d5qFHZZg2/O9xW18IpEC/dNXXPVqmDJ0c//9RMz0CILkbSGPA42+cbU0fC/ghwJ03w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/solc": { + "version": "0.8.37", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.37.tgz", + "integrity": "sha512-ieUYdPDRGNKMsZpCfi9tY64Q+ASoL8tyhLuvf9cFiqDs+6ijlx8Wn2CRi0N71AlCvRx0V092rlUYu3b3PuxWlA==", + "license": "MIT", + "dependencies": { + "command-exists": "^1.2.8", + "commander": "^8.1.0", + "follow-redirects": "^1.12.1", + "js-sha3": "0.8.0", + "memorystream": "^0.3.1", + "semver": "^5.5.0", + "tmp": "0.2.6" + }, + "bin": { + "solcjs": "solc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/viem": { + "version": "2.57.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.57.2.tgz", + "integrity": "sha512-ZbPvu+5fvRsbFh+OymDz0GyEySsSco8GWrEAiHM4ckhEY1vJTPy0tcLVkn0cs70dPiUOjLxkQBnfVyt9YkJ7cA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.45", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/tools/evm-smoke/package.json b/tools/evm-smoke/package.json new file mode 100644 index 000000000..e088f75ea --- /dev/null +++ b/tools/evm-smoke/package.json @@ -0,0 +1,15 @@ +{ + "name": "igneum-evm-smoke", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Igneum devnet v3 execution-layer smoke test: stock Ethereum tooling (viem) against the eth_ JSON-RPC of igneumd", + "scripts": { + "compile": "node compile.mjs", + "smoke": "node smoke.mjs" + }, + "dependencies": { + "solc": "^0.8.28", + "viem": "^2.21.0" + } +} diff --git a/tools/evm-smoke/smoke.mjs b/tools/evm-smoke/smoke.mjs new file mode 100644 index 000000000..25d35edde --- /dev/null +++ b/tools/evm-smoke/smoke.mjs @@ -0,0 +1,252 @@ +// 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
. +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); });