diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 478e9f47..ad465a9a 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -33,4 +33,24 @@ ignore = [ # Transitive dependency via reth-dns-discovery (reth v1.11.3) and libp2p-mdns # (libp2p 0.56.0) — both pin hickory-proto to 0.25.x; cannot update without bumping reth. "RUSTSEC-2026-0119", + + # core2 0.4.0 is unmaintained and all versions yanked; no replacement available. + # Transitive dependency via multihash 0.19.3 → multiaddr → libp2p 0.56.0. Cannot update + # without bumping the malachite/libp2p dependency chain. + "RUSTSEC-2026-0105", + + # proc-macro-error2 2.0.1 is unmaintained; no newer version available in the 2.x series. + # Transitive dependency via aquamarine → reth-transaction-pool (reth v1.11.3). + # Cannot update without bumping reth. + "RUSTSEC-2026-0173", + + # git2 0.20.4 - potential UB in Remote::list(); no patched version available. + # Transitive dependency via vergen-git2 9.1.0 → reth-node-core (reth v1.11.3). + # Cannot update without bumping reth. + "RUSTSEC-2026-0183", + + # git2 0.20.4 - potential UB with Signature from buffer-created BlameHunk; no patched version. + # Transitive dependency via vergen-git2 9.1.0 → reth-node-core (reth v1.11.3). + # Cannot update without bumping reth. + "RUSTSEC-2026-0184", ] \ No newline at end of file diff --git a/.gitignore b/.gitignore index 52df0184..8e937259 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,10 @@ node_modules/ .env.test.local .env.production.local .env.contract-ops +/public-release.env +/public-sync.env +/release.env +/release-refs.env # Rust-specific **/*.rs.bk diff --git a/.licenseignore b/.licenseignore index 19bb738e..2e64bec1 100644 --- a/.licenseignore +++ b/.licenseignore @@ -1 +1,2 @@ pkg:npm/uri-js@4.4.1 +pkg:cargo/aws-lc-sys diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 43a1881c..81f831ca 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -12,6 +12,31 @@ Entries are split by audience. A change appears under `### For Validators` when Compare and release-notes links resolve once the corresponding tag is published at [`circlefin/arc-node`](https://github.com/circlefin/arc-node). +## [v0.8.0] + +**Changes:** [v0.7.3...v0.8.0](https://github.com/circlefin/arc-node/compare/v0.7.3...v0.8.0) -- [release notes](https://github.com/circlefin/arc-node/releases/tag/v0.8.0) + +### For Node Operators + +- **[Format] JSON-RPC error text on insufficient-balance `eth_call` / `eth_estimateGas` changed with the reth 2.2 / revm 38 upgrade.** + - Value exceeds balance: the error previously read `insufficient funds for gas * price + value`; it now reflects revm 38's `OutOfFunds` variant. + - Simple (EOA-to-EOA) transfer with insufficient balance: reth 2.2 runs these RPC paths with `disable_fee_charge`, so the basic-transfer shortcut no longer applies the caller gas-allowance cap. The surfaced error shifted from `Missing or invalid parameters` to `gas required exceeds allowance`. + - Neither string is a stable API contract, but tooling that matches JSON-RPC error text on these paths must update its patterns. No consensus-affecting behavior changed; only the RPC error surface. +- **[CLI] `arc-node-consensus` admin RPC routes are disabled by default.** + - The unauthenticated `POST` and `DELETE /persistent-peers` routes are no longer mounted unless `--rpc.admin` is provided. + - Read-only RPC routes are unaffected. + - Operators using persistent-peer mutation routes must add `--rpc.admin` and restrict the RPC listener to a trusted interface. +- **[Config] Explicit invalid CL environment values now fail startup.** + - Unset or empty `ARC_*` configuration variables continue to use their defaults. + - Malformed values, and zero values for resource sizes, counts, timeouts, or thresholds that must be positive, now abort startup instead of silently falling back. + - Correct or remove invalid environment values before upgrading. +- **[CLI] `arc-node-execution` denylist configuration flags were removed and denylist checks are mandatory.** + - Remove `--arc.denylist.enabled`, `--arc.denylist.address`, and `--arc.denylist.storage-slot`. `--arc.denylist.addresses-exclusions` remains available. +- **[CLI] `arc-snapshots download` no longer defaults `--chain` to testnet and adds an execution manifest profile.** + - Add `--chain ` for automatic URL resolution. For a manifest URL, also add `--el-profile `. +- **[Config] Execution-layer pruning presets now inject a 128-block pruning interval.** + - `node --full` and `node --minimal` previously injected `5000`. Set `--prune.block-interval=5000` explicitly to retain that schedule. + ## [v0.7.3] **Changes:** [v0.7.2...v0.7.3](https://github.com/circlefin/arc-node/compare/v0.7.2...v0.7.3) -- [release notes](https://github.com/circlefin/arc-node/releases/tag/v0.7.3) @@ -63,7 +88,7 @@ No breaking changes in this release. **Changes:** [v0.6.0...v0.7.0](https://github.com/circlefin/arc-node/compare/v0.6.0...v0.7.0) -- [release notes](https://github.com/circlefin/arc-node/releases/tag/v0.7.0) -*Note: mainnet node operators must use v0.7.0. Earlier versions are not supported.* +*Note: mainnet node operators must use v0.7.0. Earlier versions are not supported.* ### For Node Operators diff --git a/CHANGELOG.md b/CHANGELOG.md index 540eb5b5..3955663f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,65 @@ All notable changes to arc-node are documented in this file. +## [v0.8.0] + +**Changes:** [v0.7.3...v0.8.0](https://github.com/circlefin/arc-node/compare/v0.7.3...v0.8.0) -- [release notes](https://github.com/circlefin/arc-node/releases/tag/v0.8.0) + +*Note: testnet node operators must use a version supporting Zero8 before timestamp `1788447600` (2026-09-03 15:00:00 UTC), when Zero8 activates on testnet. Earlier versions are not supported.* + +*Note: mainnet node operators must use a version supporting Zero7/Zero8 before timestamp `1789052400` (2026-09-10 15:00:00 UTC), when Zero7/Zero8 activate on mainnet. Earlier versions are not supported.* + +### For Node Operators + +- **[Format] JSON-RPC error text for insufficient-balance `eth_call` and `eth_estimateGas` calls changed with the reth 2.2 / revm 38 upgrade.** Transactions whose value or gas cost exceeds the sender balance now surface revm 38's `OutOfFunds` text (previously `insufficient funds for gas * price + value`); simple EOA-to-EOA transfers with insufficient balance now surface `gas required exceeds allowance` (previously `Missing or invalid parameters`). Tooling that matches on these strings must update its patterns. No consensus-affecting behavior changed; only the RPC error surface. See [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#v080) for migration details. +- **[CLI] `arc-node-consensus` admin RPC routes are disabled by default.** The new `--rpc.admin` flag enables the unauthenticated `POST` and `DELETE /persistent-peers` routes; read-only RPC routes remain available without it. Enable admin routes only on a trusted interface. See [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#v080) for migration details. +- **[Config] Explicit invalid CL environment values now fail startup.** Eleven value-sync, consensus-queue, discovery, and remote-signing tunables can now be overridden through `ARC_*` environment variables. Unset or empty variables retain their defaults; malformed values and zero values for settings that must be positive abort startup. See [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#v080) for migration details. +- **[CLI] Arc denylist checks are mandatory and their contract configuration is chain-derived.** Remove `--arc.denylist.enabled`, `--arc.denylist.address`, and `--arc.denylist.storage-slot`; `--arc.denylist.addresses-exclusions` remains available. See [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#v080) for migration details. +- **[CLI] `arc-snapshots download` no longer defaults `--chain` to testnet and requires `--el-profile` for execution manifest restores.** Supply the chain for automatic URL resolution and supply both options for a manifest URL. See [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#v080) for migration details. +- **[Config] Execution-layer pruning presets now inject `--prune.block-interval=128` instead of `5000`.** This applies to the `node` subcommand when `--full` or `--minimal` is used without an explicit interval. See [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#v080) for migration details. + +### Features + +- [EL] Upgrade to reth 2.2.0 and revm 38; brings EVM improvements and reth Storage V2 support. Nodes running existing V1 data may optionally migrate in place with `arc-node-execution db migrate-v2` (offline, one-way); the v0.8.0 binary runs V1 data without migration +- [EL] Add ordered transaction relay failover for follow nodes through `--arc.tx.relays` and `--arc.tx.relays.timeout` +- [EL] Add reth V2 modular execution snapshot restores with explicit minimal, full, and archive profiles +- [CL] Add `arc-node-consensus db migrate` command (alias: `upgrade`) to upgrade the consensus database schema to the latest version; migrations run automatically at node startup, so the manual command is a recovery tool for failed auto-migrations. Pass `--dry-run` to preview changes without committing +- [CL] Add forward height-range queries to the `/commit`, `/misbehavior-evidence`, `/proposal-monitor`, and `/invalid-payloads` endpoints, with negotiated response compression +- [CL] Add gzip compression negotiation to the CL `/metrics` endpoint +- [CL] Deprecate the RPC/HTTP transport between CL and EL in favor of IPC; RPC configurations now emit a startup warning ahead of removal in v0.9.0 +- [CL] Close completed proposal-part stream keys so resurfaced duplicate parts cannot consume streaming capacity +- [CL] Add `arc_malachite_app_transient_validation_errors_count` metrics for transient proposal-validation failures +- [CL] Record proposal-monitor latency and outcome data when proposal parts arrive before round 0 starts + +### Fixes + +- [CL] Exit with non-zero status when the EL IPC connection closes unexpectedly, enabling container orchestrators to restart the node automatically +- [CL] Generate a fresh JWT token per Engine API request to prevent token staleness during long-running consensus operations +- [CL] Reject proposal stream messages carrying an invalid Fin sequence number +- [CL] Retry EL chain-identity resolution for up to 30 seconds during startup, then exit non-zero for orchestrator restart instead of hanging indefinitely +- [CL] Refuse to restream blocks without their original proposal signature +- [CL] Reject payloads with non-canonical block hashes before storing them as undecided blocks +- [CL] Let a proposer decline the round when `getPayload` exceeds the proposal deadline instead of crashing the consensus application +- [CL] Fail fast when multiple locally built blocks exist for the same height and round +- [CL] Bound CL RPC request bodies, execution time, and concurrency to prevent slow requests from exhausting node resources +- [CL] Treat invalid-payload forensic persistence failures as diagnostic errors instead of aborting consensus-critical processing +- [CL] Stop the Malachite Node actor before application teardown during SIGTERM shutdown to avoid spurious consensus errors +- [CL] Bind each execution payload to its consensus height and the block finalized at the previous height +- [CL] Key pending proposal parts by proposer, proof-of-lock round, and signature so distinct proposal streams cannot alias in storage +- [CL] Select the expected proposer from the proposal parts' round when validating streamed proposals +- [CL] Treat `engine_newPayload` internal JSON-RPC errors as no-verdict outcomes instead of permanently recording a payload as invalid +- [EL] Gate corrected EIP-161 deletion of synthetic empty accounts behind Zero8, preserving historical pre-Zero8 state roots +- [EL] Reject Engine API payloads containing withdrawals because Arc does not support withdrawals +- [EL] Evict permanently un-includable transactions from the transaction pool after payload construction rejects them +- [EL] Preserve transaction-warm status when evaluating `SELFDESTRUCT` targets +- [EL] Validate transaction size before recovering EIP-7702 authorities for denylist checks +- [EL] Keep the sparse-trie state hook active through post-block writes so committed state roots include those writes +- [EL] Reject a block beneficiary that was self-destructed during the same block +- [EL] Apply pending-transaction subscription filtering when `eth_subscribe` uses object-form parameters +- [EL] Classify beneficiary blocklist read failures as internal execution errors instead of deterministic transaction failures +- [Shared] Update h2 to reject unbounded empty DATA-frame streams and remove the related denial-of-service vulnerability +- [Shared] Update ruint to correct overflow flags and shift-amount handling used by EVM big-integer operations + ## [v0.7.3] **Changes:** [v0.7.2...v0.7.3](https://github.com/circlefin/arc-node/compare/v0.7.2...v0.7.3) -- [release notes](https://github.com/circlefin/arc-node/releases/tag/v0.7.3) @@ -9,11 +68,13 @@ All notable changes to arc-node are documented in this file. ### Features - [CL] Add consensus and RPC Prometheus metrics on the `/metrics` endpoint: `arc_malachite_app_consensus_round_missed` (counter, labeled by the missed round's proposer), `arc_malachite_app_rpc_request_time`, and app-request channel queue-time, process-time, and full-channel-rejection metrics +- [CL] Process application and RPC requests concurrently with consensus messages so slow database-backed reads do not delay consensus progress - [EL] Add the Zero8 hardfork (dormant). Once active on a chain, a rejected delegatecall into a stateful Arc precompile is charged the uniform 200-gas early-revert penalty, matching the penalty already applied to other precompile authorization and validation reverts. Zero8 is not scheduled on any public chain - [EL] `arc_getCertificate` now accepts a hex-encoded quantity string (e.g. `"0x7"`) for the block-height parameter in addition to a plain decimal number, aligning with the EVM hex-quantity convention used by `eth_getBlockByNumber` ### Fixes +- [CL] Avoid querying the EL for a validator set when a stored certificate already includes proposer metadata - [EL] Complete the pending-block RPC filter so permissionless callers can no longer read the proposed pre-finalization block through case-variant `pending` tags, previously-uncovered block-content methods, or object-style block parameters - [Shared] Remediate cargo audit advisories via dependency bumps @@ -75,7 +136,7 @@ Full documentation tree at this release: [`arc-node` v0.7.2 docs](https://github ### For Node Operators -*Note: mainnet node operators must use v0.7.0. Earlier versions are not supported.* +*Note: mainnet node operators must use v0.7.0. Earlier versions are not supported.* - **[Config] Pending transactions are hidden from RPC by default.** Renamed `--arc.hide-pending-txs` (opt-in to hide) to `--arc.expose-pending-txs` (opt-in to expose) and flipped the default. Added `--public-api`, a convenience flag for externally-exposed nodes that forces hiding and warns if `--http.api` / `--ws.api` expose namespaces outside `{eth, net, web3, rpc}`. - **[Config] CL default log level changed from `debug` to `info`.** Pass `--log-level debug` explicitly if your monitoring depends on debug-level output. diff --git a/Cargo.lock b/Cargo.lock index 47d70219..560acdde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,9 +116,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alloy-chains" -version = "0.2.30" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f374d3c6d729268bbe2d0e0ff992bb97898b2df756691a62ee1d5f0506bc39" +checksum = "84e0378e959aa6a885897522080a990e80eb317f1e9a222a604492ea50e13096" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -131,14 +131,14 @@ dependencies = [ [[package]] name = "alloy-consensus" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c0dc44157867da82c469c13186015b86abef209bf0e41625e4b68bac61d728" +checksum = "e3d64da86c616b5092ea64eea648f311bbd58630a0b384c42d699175d6f9122b" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.4", "alloy-trie", "alloy-tx-macros", "arbitrary", @@ -159,15 +159,15 @@ dependencies = [ [[package]] name = "alloy-consensus-any" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4cdb42df3871cd6b346d6a938ec2ba69a9a0f49d1f82714bc5c48349268434" +checksum = "8fd98696ca3617d3a9ba1a6f2011880cbfd5618228dab6400c9f8bca457859a8" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.4", "arbitrary", "serde", ] @@ -237,15 +237,17 @@ dependencies = [ [[package]] name = "alloy-eip7928" -version = "0.3.2" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3231de68d5d6e75332b7489cfcc7f4dfabeba94d990a10e4b923af0e6623540" +checksum = "6b827a6d7784fe3eb3489d40699407a4cdcce74271421a01bdffe60cf573bb16" dependencies = [ "alloy-primitives", "alloy-rlp", "arbitrary", "borsh", + "once_cell", "serde", + "thiserror 2.0.18", ] [[package]] @@ -260,29 +262,52 @@ dependencies = [ "alloy-eip7928", "alloy-primitives", "alloy-rlp", - "alloy-serde", - "arbitrary", + "alloy-serde 1.7.3", "auto_impl", "borsh", "c-kzg", "derive_more", "either", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", "serde", "serde_with", "sha2 0.10.9", "thiserror 2.0.18", ] +[[package]] +name = "alloy-eips" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c0456f5f7a4497e9342d20f528e30f5288ddfa0d6a012bd5044afee46cd8a0" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde 2.0.4", + "arbitrary", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "ethereum_ssz", + "ethereum_ssz_derive", + "serde", + "serde_with", + "sha2 0.10.9", +] + [[package]] name = "alloy-evm" -version = "0.27.2" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ccfe6d724ceabd5518350cfb34f17dd3a6c3cc33579eee94d98101d3a511ff" +checksum = "c1ceeea6dcbbcd4e546b27700763a6f6c3b3fee30054209884f521078b6fda4f" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-hardforks", "alloy-primitives", "alloy-rpc-types-engine", @@ -290,21 +315,20 @@ dependencies = [ "alloy-sol-types", "auto_impl", "derive_more", - "op-alloy", - "op-revm", "revm", "thiserror 2.0.18", + "tracing", ] [[package]] name = "alloy-genesis" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9cf3b99f46615fbf7dc1add0c96553abb7bf88fc9ec70dfbe7ad0b47ba7fe8" +checksum = "a71ff8b55d2b8aa05259f474cae7dea0e4991724dc18936b81cb23ec492a0c2a" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", - "alloy-serde", + "alloy-serde 2.0.4", "alloy-trie", "borsh", "serde", @@ -339,9 +363,9 @@ dependencies = [ [[package]] name = "alloy-json-rpc" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff42cd777eea61f370c0b10f2648a1c81e0b783066cd7269228aa993afd487f7" +checksum = "19e352478b756bad5d7203148e4b461861282ea2ded3da406ba24868b52cd098" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -354,19 +378,19 @@ dependencies = [ [[package]] name = "alloy-network" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cbca04f9b410fdc51aaaf88433cbac761213905a65fe832058bcf6690585762" +checksum = "ed08ae169869e08370ed121612e0d3dadac33d1a256e9f2465926b23f0bd7d95" dependencies = [ "alloy-consensus", "alloy-consensus-any", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-json-rpc", "alloy-network-primitives", "alloy-primitives", "alloy-rpc-types-any", "alloy-rpc-types-eth", - "alloy-serde", + "alloy-serde 2.0.4", "alloy-signer", "alloy-sol-types", "async-trait", @@ -380,14 +404,14 @@ dependencies = [ [[package]] name = "alloy-network-primitives" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d6d15e069a8b11f56bef2eccbad2a873c6dd4d4c81d04dda29710f5ea52f04" +checksum = "02e6c7ad28afe348a9a9c5624b67ee5b3607b8de98d5816b3056ecdfa6fa2697" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", - "alloy-serde", + "alloy-serde 2.0.4", "serde", ] @@ -424,13 +448,13 @@ dependencies = [ [[package]] name = "alloy-provider" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d181c8cc7cf4805d7e589bf4074d56d55064fa1a979f005a45a62b047616d870" +checksum = "93a7c17472b55482d4734154c2f5ed13f72e03f6752cebb927f6a2d8b52e646c" dependencies = [ "alloy-chains", "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-json-rpc", "alloy-network", "alloy-network-primitives", @@ -454,10 +478,10 @@ dependencies = [ "either", "futures", "futures-utils-wasm", - "lru 0.16.3", + "lru", "parking_lot", "pin-project", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "thiserror 2.0.18", @@ -469,9 +493,9 @@ dependencies = [ [[package]] name = "alloy-pubsub" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8bd82953194dec221aa4cbbbb0b1e2df46066fe9d0333ac25b43a311e122d13" +checksum = "a8d86958b02bca85103d64fa60d7b364a8b017c6e40f2b02c3f50ca22964a738" dependencies = [ "alloy-json-rpc", "alloy-primitives", @@ -513,9 +537,9 @@ dependencies = [ [[package]] name = "alloy-rpc-client" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2792758a93ae32a32e9047c843d536e1448044f78422d71bf7d7c05149e103f" +checksum = "5beb5c2fe6b960c8e8b038e69fd502a90a2e930afa4770efb748b163b0767729" dependencies = [ "alloy-json-rpc", "alloy-primitives", @@ -526,7 +550,7 @@ dependencies = [ "alloy-transport-ws", "futures", "pin-project", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "tokio", @@ -539,22 +563,22 @@ dependencies = [ [[package]] name = "alloy-rpc-types" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bdcbf9dfd5eea8bfeb078b1d906da8cd3a39c4d4dbe7a628025648e323611f6" +checksum = "4ee1257a278f6d293e05c5162c5940a1561b1aa85ded0028b464c81de37ebfa5" dependencies = [ "alloy-primitives", "alloy-rpc-types-engine", "alloy-rpc-types-eth", - "alloy-serde", + "alloy-serde 2.0.4", "serde", ] [[package]] name = "alloy-rpc-types-admin" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42325c117af3a9e49013f881c1474168db57978e02085fc9853a1c89e0562740" +checksum = "e2144d5b2866e651796eac0a997d3b5a056449c12e0d91be3184129e0c722885" dependencies = [ "alloy-genesis", "alloy-primitives", @@ -564,39 +588,43 @@ dependencies = [ [[package]] name = "alloy-rpc-types-anvil" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a3100b76987c1b1dc81f3abe592b7edc29e92b1242067a69d65e0030b35cf9" +checksum = "df32156f085e74eac942b6103744be49b817c302341aaa8cb0c1c88dc29228d9" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", - "alloy-serde", + "alloy-serde 2.0.4", "serde", ] [[package]] name = "alloy-rpc-types-any" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd720b63f82b457610f2eaaf1f32edf44efffe03ae25d537632e7d23e7929e1a" +checksum = "6a234bfbdf7a76c3d13808f729af5321852de3dedcaa6fc6d5f54787aaf54c6a" dependencies = [ "alloy-consensus-any", + "alloy-network-primitives", + "alloy-primitives", "alloy-rpc-types-eth", - "alloy-serde", + "alloy-serde 2.0.4", + "serde", + "serde_json", ] [[package]] name = "alloy-rpc-types-beacon" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a22e13215866f5dfd5d3278f4c41f1fad9410dc68ce39022f58593c873c26f8" +checksum = "296450f5e76bece0116c939b9437b0421a5da9c5d40031bf4cf9b38d3d94e475" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rpc-types-engine", "derive_more", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", + "ethereum_ssz", + "ethereum_ssz_derive", "serde", "serde_json", "serde_with", @@ -607,11 +635,12 @@ dependencies = [ [[package]] name = "alloy-rpc-types-debug" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b21e1ad18ff1b31ff1030e046462ab8168cf8894e6778cd805c8bdfe2bd649" +checksum = "0ab075ac1c25bcf697f133b7cd92e2fb26afe213e872ef79fdf77f0d7bcb3793" dependencies = [ "alloy-primitives", + "alloy-rlp", "derive_more", "serde", "serde_with", @@ -619,20 +648,20 @@ dependencies = [ [[package]] name = "alloy-rpc-types-engine" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4ac61f03f1edabccde1c687b5b25fff28f183afee64eaa2e767def3929e4457" +checksum = "73b12366c96f4013e1aeebc96c6b56e5f33f07853c42ea2f485045c0c157a4a1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.4", "arbitrary", "derive_more", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", - "jsonwebtoken", + "ethereum_ssz", + "ethereum_ssz_derive", + "jsonwebtoken 10.4.0", "rand 0.8.5", "serde", "strum 0.27.2", @@ -640,17 +669,17 @@ dependencies = [ [[package]] name = "alloy-rpc-types-eth" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2dc411f13092f237d2bf6918caf80977fc2f51485f9b90cb2a2f956912c8c9" +checksum = "56a282daf869eeb7383d3d5c2deb35b0b3fb45ecb329513af4090fc61245ee18" dependencies = [ "alloy-consensus", "alloy-consensus-any", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-network-primitives", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.4", "alloy-sol-types", "arbitrary", "itertools 0.13.0", @@ -662,28 +691,28 @@ dependencies = [ [[package]] name = "alloy-rpc-types-mev" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe85bf3be739126aa593dca9fb3ab13ca93fa7873e6f2247be64d7f2cb15f34a" +checksum = "7adc1243d55744a66b3a6cbbbba96436e8df5d248f2ee8186bef4238ef704ec7" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rpc-types-eth", - "alloy-serde", + "alloy-serde 2.0.4", "serde", "serde_json", ] [[package]] name = "alloy-rpc-types-trace" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad79f1e27e161943b5a4f99fe5534ef0849876214be411e0032c12f38e94daa" +checksum = "6184b5d14152b68b0bb8beb621339d94f0b761a37958bb365fbf7c00922125c2" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", - "alloy-serde", + "alloy-serde 2.0.4", "serde", "serde_json", "thiserror 2.0.18", @@ -691,13 +720,13 @@ dependencies = [ [[package]] name = "alloy-rpc-types-txpool" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d459f902a2313737bc66d18ed094c25d2aeb268b74d98c26bbbda2aa44182ab0" +checksum = "f00b631c361e7c7baaf4f1f5a9877730f3507fed2acb9d4b34841b8184b2ec28" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", - "alloy-serde", + "alloy-serde 2.0.4", "serde", ] @@ -706,6 +735,17 @@ name = "alloy-serde" version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2ce1e0dbf7720eee747700e300c99aac01b1a95bb93f493a01e78ee28bb1a37" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-serde" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eada2558e921b39dfcead33c487364df9b31374f5733c1c9d2c891c4529933" dependencies = [ "alloy-primitives", "arbitrary", @@ -715,9 +755,9 @@ dependencies = [ [[package]] name = "alloy-signer" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2425c6f314522c78e8198979c8cbf6769362be4da381d4152ea8eefce383535d" +checksum = "41eb29f7a8adcd8941fbb8e134022a133e6f8dfd345f2e3b7109599f8a7dca08" dependencies = [ "alloy-primitives", "async-trait", @@ -730,9 +770,9 @@ dependencies = [ [[package]] name = "alloy-signer-local" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ecb71ee53d8d9c3fa7bac17542c8116ebc7a9726c91b1bf333ec3d04f5a789" +checksum = "bef839e7ce9b59aa60fa9a175e97986c6145c888d643b0f1fb0a3e7b8e56a2e2" dependencies = [ "alloy-consensus", "alloy-network", @@ -819,9 +859,9 @@ dependencies = [ [[package]] name = "alloy-transport" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa186e560d523d196580c48bf00f1bf62e63041f28ecf276acc22f8b27bb9f53" +checksum = "3ac7a80c0bac3e44559d53d002e34c461dc2f23262b42cafec019bc70551abbe" dependencies = [ "alloy-json-rpc", "auto_impl", @@ -842,14 +882,14 @@ dependencies = [ [[package]] name = "alloy-transport-http" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa501ad58dd20acddbfebc65b52e60f05ebf97c52fa40d1b35e91f5e2da0ad0e" +checksum = "eed3ed3300a998f88639ed619fdbbd88bd82865e00c6a8ecb796c99eb12358f6" dependencies = [ "alloy-json-rpc", "alloy-transport", "itertools 0.13.0", - "reqwest", + "reqwest 0.13.3", "serde_json", "tower 0.5.3", "tracing", @@ -858,9 +898,9 @@ dependencies = [ [[package]] name = "alloy-transport-ipc" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ef85688e5ac2da72afc804e0a1f153a1f309f05a864b1998bbbed7804dbaab" +checksum = "1075d9d30fd4d71e50000fd4afb19ed2664ceab20c2a29f3889a6e988329e02d" dependencies = [ "alloy-json-rpc", "alloy-pubsub", @@ -878,18 +918,20 @@ dependencies = [ [[package]] name = "alloy-transport-ws" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f00445db69d63298e2b00a0ea1d859f00e6424a3144ffc5eba9c31da995e16" +checksum = "0e3bff84b2b2a46eb34cc522dc3f889a2867c70be90a377421429b662b3ec4ce" dependencies = [ "alloy-pubsub", "alloy-transport", "futures", "http", + "rustls", "serde_json", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.28.0", "tracing", + "url", "ws_stream_wasm", ] @@ -916,11 +958,11 @@ dependencies = [ [[package]] name = "alloy-tx-macros" -version = "1.7.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa0c53e8c1e1ef4d01066b01c737fb62fc9397ab52c6e7bb5669f97d281b9bc" +checksum = "99fce0350197dcd4ba4e9a7dd43915d908c0eb0e7352755791709a705e1c76b6" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -977,7 +1019,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -988,7 +1030,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1032,7 +1074,7 @@ dependencies = [ "futures-util", "prometheus-parse", "redb", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "tokio", @@ -1052,7 +1094,7 @@ dependencies = [ "arc-malachitebft-proto", "bytes", "bytesize", - "ethereum_ssz 0.9.1", + "ethereum_ssz", "mockall", "prost 0.13.5", "redb", @@ -1067,6 +1109,7 @@ name = "arc-consensus-types" version = "0.0.1" dependencies = [ "alloy-consensus", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", @@ -1076,6 +1119,7 @@ dependencies = [ "arc-malachitebft-codec", "arc-malachitebft-core-consensus", "arc-malachitebft-core-types", + "arc-malachitebft-engine-byzantine", "arc-malachitebft-proto", "arc-malachitebft-signing", "arc-malachitebft-signing-ed25519", @@ -1086,8 +1130,8 @@ dependencies = [ "bytes", "bytesize", "config", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", + "ethereum_ssz", + "ethereum_ssz_derive", "eyre", "humantime-serde", "prost 0.13.5", @@ -1112,6 +1156,7 @@ dependencies = [ "alloy-genesis", "alloy-primitives", "alloy-rpc-types-engine", + "alloy-rpc-types-eth", "arc-eth-engine", "arc-execution-config", "arc-version", @@ -1120,7 +1165,7 @@ dependencies = [ "color-eyre", "csv", "eyre", - "reqwest", + "reqwest 0.13.3", "reth-cli", "serde", "serde_json", @@ -1135,7 +1180,7 @@ name = "arc-eth-engine" version = "0.0.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-genesis", "alloy-primitives", "alloy-rpc-types", @@ -1157,9 +1202,9 @@ dependencies = [ "hex", "jsonrpsee", "jsonrpsee-types", - "jsonwebtoken", + "jsonwebtoken 9.3.1", "mockall", - "reqwest", + "reqwest 0.13.3", "reth-chainspec", "reth-ipc", "reth-node-builder", @@ -1181,7 +1226,7 @@ name = "arc-evm" version = "0.0.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-evm", "alloy-genesis", "alloy-primitives", @@ -1198,6 +1243,7 @@ dependencies = [ "reth-primitives-traits", "revm", "revm-context-interface", + "revm-handler", "revm-inspectors", "revm-interpreter", "revm-primitives", @@ -1211,10 +1257,14 @@ name = "arc-evm-node" version = "0.0.1" dependencies = [ "alloy-consensus", + "alloy-eips 2.0.4", "alloy-network", "alloy-primitives", + "alloy-rlp", + "alloy-rpc-client", "alloy-rpc-types-engine", "alloy-rpc-types-eth", + "alloy-transport", "arc-consensus-types", "arc-evm", "arc-execution-config", @@ -1224,10 +1274,14 @@ dependencies = [ "arc-version", "async-trait", "backon", + "bytesize", + "ethereum_ssz", "eyre", "jsonrpsee", - "reqwest", + "metrics", + "reqwest 0.13.3", "reth-chainspec", + "reth-consensus-common", "reth-engine-primitives", "reth-ethereum", "reth-ethereum-engine-primitives", @@ -1289,11 +1343,11 @@ dependencies = [ name = "arc-execution-config" version = "0.0.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-evm", "alloy-genesis", "alloy-primitives", - "alloy-serde", + "alloy-serde 2.0.4", "alloy-sol-types", "arc-shared", "clap", @@ -1322,12 +1376,14 @@ name = "arc-execution-e2e" version = "0.0.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-network", "alloy-primitives", "alloy-rpc-types-engine", "alloy-rpc-types-eth", "alloy-rpc-types-trace", + "alloy-serde 2.0.4", + "alloy-signer-local", "alloy-sol-types", "arc-evm-node", "arc-execution-config", @@ -1366,6 +1422,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "arc-execution-txpool", + "arc-precompiles", "eyre", "metrics", "reth-basic-payload-builder", @@ -1377,14 +1434,18 @@ dependencies = [ "reth-ethereum-payload-builder", "reth-ethereum-primitives", "reth-evm", + "reth-evm-ethereum", + "reth-execution-cache", "reth-node-api", "reth-node-builder", "reth-payload-builder", "reth-payload-primitives", "reth-primitives-traits", + "reth-provider", "reth-revm", "reth-storage-api", "reth-transaction-pool", + "reth-trie-parallel", "revm", "tokio", "tracing", @@ -1396,7 +1457,7 @@ name = "arc-execution-txpool" version = "0.0.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "arc-execution-config", "arc-execution-validation", @@ -1430,7 +1491,7 @@ name = "arc-execution-validation" version = "0.0.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "arc-execution-config", "mockall", @@ -1447,8 +1508,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-app" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-codec", "arc-malachitebft-config", @@ -1475,12 +1536,13 @@ dependencies = [ [[package]] name = "arc-malachitebft-app-channel" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-app", "arc-malachitebft-config", "arc-malachitebft-engine", + "arc-malachitebft-engine-byzantine", "arc-malachitebft-signing", "bytes", "derive-where", @@ -1493,30 +1555,29 @@ dependencies = [ [[package]] name = "arc-malachitebft-codec" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "bytes", ] [[package]] name = "arc-malachitebft-config" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ - "arc-malachitebft-core-types", "bytesize", - "config", "humantime-serde", "multiaddr", "serde", + "thiserror 2.0.18", "tracing", ] [[package]] name = "arc-malachitebft-core-consensus" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-core-driver", "arc-malachitebft-core-types", @@ -1535,8 +1596,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-driver" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-core-state-machine", "arc-malachitebft-core-types", @@ -1548,8 +1609,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-state-machine" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-core-types", "derive-where", @@ -1558,8 +1619,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-types" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-peer", "async-trait", @@ -1571,8 +1632,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-votekeeper" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-core-types", "derive-where", @@ -1582,8 +1643,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-discovery" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-metrics", "either", @@ -1597,8 +1658,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-engine" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-codec", "arc-malachitebft-config", @@ -1628,10 +1689,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "arc-malachitebft-engine-byzantine" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" +dependencies = [ + "arc-malachitebft-core-consensus", + "arc-malachitebft-core-types", + "arc-malachitebft-engine", + "arc-malachitebft-signing", + "async-trait", + "eyre", + "ractor", + "rand 0.8.5", + "serde", + "tracing", +] + [[package]] name = "arc-malachitebft-metrics" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-core-state-machine", "prometheus-client", @@ -1639,8 +1717,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-network" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-discovery", "arc-malachitebft-metrics", @@ -1657,7 +1735,6 @@ dependencies = [ "libp2p", "libp2p-gossipsub", "libp2p-scatter", - "libp2p-stream", "seahash", "serde", "thiserror 2.0.18", @@ -1668,8 +1745,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-peer" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "bs58", "multihash", @@ -1679,8 +1756,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-proto" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "prost 0.13.5", "prost-types 0.13.5", @@ -1689,8 +1766,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-signing" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-core-types", "async-trait", @@ -1699,8 +1776,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-signing-ed25519" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-core-types", "base64 0.22.1", @@ -1712,8 +1789,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-sync" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "arc-malachitebft-core-types", "arc-malachitebft-metrics", @@ -1734,8 +1811,8 @@ dependencies = [ [[package]] name = "arc-malachitebft-wal" -version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +version = "0.8.0" +source = "git+https://github.com/circlefin/malachite.git?tag=v0.8.0#72143f6c99a98452b587e1c392bdb80944eb2232" dependencies = [ "advisory-lock", "bytes", @@ -1751,7 +1828,7 @@ dependencies = [ "color-eyre", "futures-util", "prometheus-parse", - "reqwest", + "reqwest 0.13.3", "tokio", "url", ] @@ -1774,6 +1851,7 @@ dependencies = [ "arc-malachitebft-app-channel", "arc-malachitebft-core-state-machine", "arc-malachitebft-core-types", + "arc-malachitebft-engine-byzantine", "arc-malachitebft-network", "arc-malachitebft-peer", "arc-malachitebft-sync", @@ -1787,11 +1865,13 @@ dependencies = [ "backon", "base64 0.22.1", "bon", + "brotli", "bytes", "bytesize", "chrono", - "ethereum_ssz 0.9.1", + "ethereum_ssz", "eyre", + "flate2", "futures", "hex", "humantime", @@ -1806,7 +1886,7 @@ dependencies = [ "ractor", "rand 0.8.5", "redb", - "reqwest", + "reqwest 0.13.3", "schnellru", "serde", "serde_json", @@ -1821,8 +1901,10 @@ dependencies = [ "tokio-tungstenite 0.26.2", "tokio-util", "tower 0.5.3", + "tower-http", "tracing", "url", + "zstd", ] [[package]] @@ -1845,16 +1927,19 @@ dependencies = [ "itertools 0.14.0", "lz4", "rand 0.8.5", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "tar", "tempfile", "thiserror 2.0.18", "tokio", + "tower 0.5.3", + "tower-http", "tracing", "tracing-appender", "tracing-subscriber", + "tracing-test", "url", "wiremock", ] @@ -1905,6 +1990,18 @@ dependencies = [ "tonic-build", "tracing", "tracing-test", + "url", +] + +[[package]] +name = "arc-pq-precompile" +version = "0.0.1" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "revm", + "revm-interpreter", + "slh-dsa", ] [[package]] @@ -1917,6 +2014,7 @@ dependencies = [ "alloy-sol-types", "arc-evm", "arc-execution-config", + "arc-pq-precompile", "criterion", "reth-chainspec", "reth-ethereum", @@ -1991,7 +2089,7 @@ dependencies = [ "directories", "eyre", "lz4", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "tar", @@ -2052,6 +2150,12 @@ dependencies = [ "vergen-git2", ] +[[package]] +name = "archery" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" + [[package]] name = "ark-bls12-381" version = "0.5.0" @@ -2155,6 +2259,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff-asm" version = "0.3.0" @@ -2185,6 +2306,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-ff-macros" version = "0.3.0" @@ -2223,6 +2354,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-poly" version = "0.5.0" @@ -2295,13 +2439,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ - "ark-serialize-derive", + "ark-serialize-derive 0.5.0", "ark-std 0.5.0", "arrayvec", "digest 0.10.7", "num-bigint", ] +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + [[package]] name = "ark-serialize-derive" version = "0.5.0" @@ -2313,6 +2470,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-std" version = "0.3.0" @@ -2343,6 +2511,16 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -2613,6 +2791,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.7.9" @@ -2746,7 +2947,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2825,7 +3026,7 @@ dependencies = [ "bitflags 2.11.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -2909,6 +3110,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" + [[package]] name = "bitvec" version = "1.0.1" @@ -2931,6 +3138,20 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -3273,11 +3494,21 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "c-kzg" -version = "2.1.6" +version = "2.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a0f582957c24870b7bfd12bf562c40b4734b533cafbaf8ded31d6d85f462c01" +checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" dependencies = [ "arbitrary", "blst", @@ -3396,17 +3627,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.0", -] - [[package]] name = "chacha20poly1305" version = "0.10.1" @@ -3414,7 +3634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20 0.9.1", + "chacha20", "cipher", "poly1305", "zeroize", @@ -3431,7 +3651,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3534,6 +3754,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.2" @@ -3759,6 +3988,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -3920,9 +4155,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] @@ -4131,7 +4375,6 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "serde", "strsim", "syn 2.0.117", ] @@ -4145,6 +4388,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "serde", "strsim", "syn 2.0.117", ] @@ -4221,7 +4465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -4450,16 +4694,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" dependencies = [ - "dirs-sys 0.4.1", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys 0.5.0", + "dirs-sys", ] [[package]] @@ -4480,22 +4715,10 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users 0.4.6", + "redox_users", "windows-sys 0.48.0", ] -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.59.0", -] - [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -4503,15 +4726,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users 0.4.6", + "redox_users", "winapi", ] [[package]] name = "discv5" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f170f4f6ed0e1df52bf43b403899f0081917ecf1500bfe312505cc3b515a8899" +version = "0.10.4" +source = "git+https://github.com/sigp/discv5?rev=7663c00#7663c00ee0837ea98547caaedede95d9d6736f4d" dependencies = [ "aes", "aes-gcm", @@ -4522,18 +4744,17 @@ dependencies = [ "enr", "fnv", "futures", - "hashlink 0.9.1", + "hashlink 0.11.0", "hex", "hkdf", "lazy_static", "libp2p-identity", - "lru 0.12.5", "more-asserts", "multiaddr", "parking_lot", "rand 0.8.5", "smallvec", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tracing", "uint 0.10.0", @@ -4803,9 +5024,9 @@ dependencies = [ [[package]] name = "ethereum_hashing" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c853bd72c9e5787f8aafc3df2907c2ed03cff3150c3acd94e2e53a98ab70a8ab" +checksum = "5aa93f58bb1eb3d1e556e4f408ef1dac130bad01ac37db4e7ade45de40d1c86a" dependencies = [ "cpufeatures 0.2.17", "ring", @@ -4825,21 +5046,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "ethereum_ssz" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dcddb2554d19cde19b099fadddde576929d7a4d0c1cd3512d1fd95cf174375c" -dependencies = [ - "alloy-primitives", - "ethereum_serde_utils", - "itertools 0.13.0", - "serde", - "serde_derive", - "smallvec", - "typenum", -] - [[package]] name = "ethereum_ssz" version = "0.10.1" @@ -4855,18 +5061,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ethereum_ssz_derive" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a657b6b3b7e153637dc6bdc6566ad9279d9ee11a15b12cfb24a2e04360637e9f" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "ethereum_ssz_derive" version = "0.10.1" @@ -5133,6 +5327,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -5361,7 +5561,6 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -5483,9 +5682,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -5559,7 +5758,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -5594,6 +5792,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "hdrhistogram" version = "7.5.4" @@ -5837,7 +6044,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.6", ] [[package]] @@ -5886,7 +6092,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -6102,6 +6308,32 @@ dependencies = [ "xmltree", ] +[[package]] +name = "imbl" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e525189e5f603908d0c6e0d402cb5de9c4b2c8866151fabc4ebd771ed2630a2e" +dependencies = [ + "arbitrary", + "archery", + "bitmaps", + "imbl-sized-chunks", + "rand_core 0.9.5", + "rand_xoshiro", + "serde_core", + "version_check", + "wide", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" +dependencies = [ + "bitmaps", +] + [[package]] name = "impl-codec" version = "0.6.0" @@ -6358,19 +6590,68 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -6424,7 +6705,7 @@ dependencies = [ "pin-project", "rustls", "rustls-pki-types", - "rustls-platform-verifier", + "rustls-platform-verifier 0.5.3", "soketto", "thiserror 2.0.18", "tokio", @@ -6476,7 +6757,7 @@ dependencies = [ "jsonrpsee-core", "jsonrpsee-types", "rustls", - "rustls-platform-verifier", + "rustls-platform-verifier 0.5.3", "serde", "serde_json", "thiserror 2.0.18", @@ -6578,6 +6859,24 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature 2.2.0", + "simple_asn1", + "zeroize", +] + [[package]] name = "k256" version = "0.13.4" @@ -6667,9 +6966,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libgit2-sys" @@ -6690,7 +6989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7034,20 +7333,6 @@ dependencies = [ "unsigned-varint 0.8.0", ] -[[package]] -name = "libp2p-stream" -version = "0.4.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6bd8025c80205ec2810cfb28b02f362ab48a01bee32c50ab5f12761e033464" -dependencies = [ - "futures", - "libp2p-core", - "libp2p-identity", - "libp2p-swarm", - "rand 0.8.5", - "tracing", -] - [[package]] name = "libp2p-swarm" version = "0.47.1" @@ -7092,7 +7377,7 @@ dependencies = [ "if-watch", "libc", "libp2p-core", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tracing", ] @@ -7168,6 +7453,21 @@ dependencies = [ "redox_syscall 0.7.1", ] +[[package]] +name = "librocksdb-sys" +version = "0.17.3+10.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" +dependencies = [ + "bindgen", + "bzip2-sys", + "cc", + "libc", + "libz-sys", + "lz4-sys", + "zstd-sys", +] + [[package]] name = "libz-sys" version = "1.1.23" @@ -7279,15 +7579,6 @@ dependencies = [ "logos-codegen", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru" version = "0.16.3" @@ -7407,9 +7698,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -7546,9 +7837,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -8053,132 +8344,16 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" name = "oneline-eyre" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862f17a1e689c0ce8ca158ea48e776c5101c5d14fdfbed3e01c15f89604c3097" -dependencies = [ - "eyre", -] - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "op-alloy" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9b8fee21003dd4f076563de9b9d26f8c97840157ef78593cd7f262c5ca99848" -dependencies = [ - "op-alloy-consensus", - "op-alloy-network", - "op-alloy-provider", - "op-alloy-rpc-types", - "op-alloy-rpc-types-engine", -] - -[[package]] -name = "op-alloy-consensus" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "736381a95471d23e267263cfcee9e1d96d30b9754a94a2819148f83379de8a86" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-network", - "alloy-primitives", - "alloy-rlp", - "alloy-rpc-types-eth", - "alloy-serde", - "arbitrary", - "derive_more", - "serde", - "serde_with", - "thiserror 2.0.18", -] - -[[package]] -name = "op-alloy-network" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4034183dca6bff6632e7c24c92e75ff5f0eabb58144edb4d8241814851334d47" -dependencies = [ - "alloy-consensus", - "alloy-network", - "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth", - "alloy-signer", - "op-alloy-consensus", - "op-alloy-rpc-types", -] - -[[package]] -name = "op-alloy-provider" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6753d90efbaa8ea8bcb89c1737408ca85fa60d7adb875049d3f382c063666f86" -dependencies = [ - "alloy-network", - "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-engine", - "alloy-transport", - "async-trait", - "op-alloy-rpc-types-engine", -] - -[[package]] -name = "op-alloy-rpc-types" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd87c6b9e5b6eee8d6b76f41b04368dca0e9f38d83338e5b00e730c282098a4" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-network-primitives", - "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-serde", - "derive_more", - "op-alloy-consensus", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "op-alloy-rpc-types-engine" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727699310a18cdeed32da3928c709e2704043b6584ed416397d5da65694efc" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "alloy-rpc-types-engine", - "alloy-serde", - "derive_more", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", - "op-alloy-consensus", - "serde", - "sha2 0.10.9", - "snap", - "thiserror 2.0.18", +checksum = "862f17a1e689c0ce8ca158ea48e776c5101c5d14fdfbed3e01c15f89604c3097" +dependencies = [ + "eyre", ] [[package]] -name = "op-revm" -version = "15.0.0" +name = "oorandom" +version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79c92b75162c2ed1661849fa51683b11254a5b661798360a2c24be918edafd40" -dependencies = [ - "auto_impl", - "revm", - "serde", -] +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "opaque-debug" @@ -8264,7 +8439,7 @@ dependencies = [ "bytes", "http", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -8279,7 +8454,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost 0.14.3", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "tokio", "tonic 0.14.5", @@ -8360,7 +8535,6 @@ version = "3.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" dependencies = [ - "arbitrary", "arrayvec", "bitvec", "byte-slice-cast", @@ -8410,7 +8584,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -9075,7 +9249,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", "heck", - "itertools 0.12.1", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -9095,7 +9269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.12.1", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -9115,7 +9289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -9128,7 +9302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -9141,7 +9315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -9249,7 +9423,7 @@ dependencies = [ "quake-macros", "rand 0.8.5", "regex", - "reqwest", + "reqwest 0.13.3", "reth-network-peers", "rmcp", "schemars 1.2.1", @@ -9334,7 +9508,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -9343,10 +9517,11 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -9371,7 +9546,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.59.0", ] @@ -9441,17 +9616,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.1", - "rand_core 0.10.0", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -9549,7 +9713,7 @@ dependencies = [ "indoc", "itertools 0.14.0", "kasuari", - "lru 0.16.3", + "lru", "strum 0.27.2", "thiserror 2.0.18", "unicode-segmentation", @@ -9674,17 +9838,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -9755,6 +9908,46 @@ name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", @@ -9775,8 +9968,8 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", "rustls-pki-types", + "rustls-platform-verifier 0.7.0", "serde", "serde_json", "serde_urlencoded", @@ -9793,7 +9986,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.6", ] [[package]] @@ -9804,16 +9996,17 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "reth-basic-payload-builder" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "futures-core", "futures-util", "metrics", "reth-chain-state", + "reth-execution-cache", "reth-metrics", "reth-payload-builder", "reth-payload-builder-primitives", @@ -9822,17 +10015,19 @@ dependencies = [ "reth-revm", "reth-storage-api", "reth-tasks", + "reth-trie-parallel", + "serde", "tokio", "tracing", ] [[package]] name = "reth-chain-state" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-signer", "alloy-signer-local", @@ -9860,12 +10055,12 @@ dependencies = [ [[package]] name = "reth-chainspec" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-chains", "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-evm", "alloy-genesis", "alloy-primitives", @@ -9880,8 +10075,8 @@ dependencies = [ [[package]] name = "reth-cli" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-genesis", "clap", @@ -9889,20 +10084,20 @@ dependencies = [ "reth-cli-runner", "reth-db", "serde_json", - "shellexpand", ] [[package]] name = "reth-cli-commands" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-chains", "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", "backon", + "blake3", "clap", "comfy-table", "crossterm", @@ -9916,7 +10111,8 @@ dependencies = [ "metrics", "parking_lot", "ratatui", - "reqwest", + "rayon", + "reqwest 0.13.3", "reth-chainspec", "reth-cli", "reth-cli-runner", @@ -9951,14 +10147,15 @@ dependencies = [ "reth-primitives-traits", "reth-provider", "reth-prune", + "reth-prune-types", "reth-revm", "reth-stages", + "reth-stages-types", "reth-static-file", "reth-static-file-types", "reth-storage-api", "reth-tasks", "reth-trie", - "reth-trie-common", "reth-trie-db", "secp256k1 0.30.0", "serde", @@ -9974,8 +10171,8 @@ dependencies = [ [[package]] name = "reth-cli-runner" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "reth-tasks", "tokio", @@ -9984,10 +10181,10 @@ dependencies = [ [[package]] name = "reth-cli-util" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "cfg-if", "eyre", @@ -10001,18 +10198,19 @@ dependencies = [ [[package]] name = "reth-codecs" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce542a96bf888f31854803e80b3340bc233927743aa580838014e8a88fe0d66" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-genesis", "alloy-primitives", "alloy-trie", "arbitrary", "bytes", "modular-bitfield", - "op-alloy-consensus", + "parity-scale-codec", "reth-codecs-derive", "reth-zstd-compressors", "serde", @@ -10021,8 +10219,9 @@ dependencies = [ [[package]] name = "reth-codecs-derive" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634c90f1cc0f9887680ca785b0b21aa961070b9465917bf65afaec56a6d005bb" dependencies = [ "proc-macro2", "quote", @@ -10031,8 +10230,8 @@ dependencies = [ [[package]] name = "reth-config" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "eyre", "humantime-serde", @@ -10047,8 +10246,8 @@ dependencies = [ [[package]] name = "reth-consensus" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -10060,11 +10259,12 @@ dependencies = [ [[package]] name = "reth-consensus-common" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", + "alloy-primitives", "reth-chainspec", "reth-consensus", "reth-primitives-traits", @@ -10072,11 +10272,11 @@ dependencies = [ [[package]] name = "reth-consensus-debug-client" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-json-rpc", "alloy-primitives", "alloy-provider", @@ -10086,7 +10286,7 @@ dependencies = [ "derive_more", "eyre", "futures", - "reqwest", + "reqwest 0.13.3", "reth-node-api", "reth-primitives-traits", "reth-tracing", @@ -10098,15 +10298,17 @@ dependencies = [ [[package]] name = "reth-db" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "derive_more", "eyre", + "libc", "metrics", "page_size", "parking_lot", + "quanta", "reth-db-api", "reth-fs-util", "reth-libmdbx", @@ -10125,11 +10327,10 @@ dependencies = [ [[package]] name = "reth-db-api" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-genesis", "alloy-primitives", "arbitrary", "arrayvec", @@ -10137,8 +10338,6 @@ dependencies = [ "derive_more", "metrics", "modular-bitfield", - "op-alloy-consensus", - "parity-scale-codec", "proptest", "reth-codecs", "reth-db-models", @@ -10154,8 +10353,8 @@ dependencies = [ [[package]] name = "reth-db-common" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-genesis", @@ -10184,10 +10383,10 @@ dependencies = [ [[package]] name = "reth-db-models" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "arbitrary", "bytes", @@ -10199,8 +10398,8 @@ dependencies = [ [[package]] name = "reth-discv4" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -10224,8 +10423,8 @@ dependencies = [ [[package]] name = "reth-discv5" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -10248,8 +10447,8 @@ dependencies = [ [[package]] name = "reth-dns-discovery" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "dashmap", @@ -10272,11 +10471,11 @@ dependencies = [ [[package]] name = "reth-downloaders" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", "async-compression", @@ -10307,11 +10506,11 @@ dependencies = [ [[package]] name = "reth-e2e-test-utils" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-network", "alloy-primitives", "alloy-provider", @@ -10343,7 +10542,6 @@ dependencies = [ "reth-payload-builder", "reth-payload-builder-primitives", "reth-payload-primitives", - "reth-primitives", "reth-primitives-traits", "reth-provider", "reth-rpc-api", @@ -10365,8 +10563,8 @@ dependencies = [ [[package]] name = "reth-ecies" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "aes", "alloy-primitives", @@ -10393,8 +10591,8 @@ dependencies = [ [[package]] name = "reth-engine-local" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -10416,11 +10614,11 @@ dependencies = [ [[package]] name = "reth-engine-primitives" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rpc-types-engine", "auto_impl", @@ -10439,44 +10637,22 @@ dependencies = [ "tokio", ] -[[package]] -name = "reth-engine-service" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" -dependencies = [ - "futures", - "pin-project", - "reth-chainspec", - "reth-consensus", - "reth-engine-primitives", - "reth-engine-tree", - "reth-evm", - "reth-network-p2p", - "reth-node-types", - "reth-payload-builder", - "reth-provider", - "reth-prune", - "reth-stages-api", - "reth-tasks", - "reth-trie-db", -] - [[package]] name = "reth-engine-tree" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-eip7928", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-evm", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", "crossbeam-channel", "derive_more", - "fixed-cache", "futures", + "indexmap 2.13.0", "metrics", "moka", "parking_lot", @@ -10489,6 +10665,7 @@ dependencies = [ "reth-errors", "reth-ethereum-primitives", "reth-evm", + "reth-execution-cache", "reth-execution-types", "reth-metrics", "reth-network-p2p", @@ -10512,7 +10689,6 @@ dependencies = [ "revm", "revm-primitives", "schnellru", - "smallvec", "thiserror 2.0.18", "tokio", "tracing", @@ -10520,8 +10696,8 @@ dependencies = [ [[package]] name = "reth-engine-util" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-rpc-types-engine", @@ -10548,29 +10724,30 @@ dependencies = [ [[package]] name = "reth-era" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", - "ethereum_ssz 0.10.1", - "ethereum_ssz_derive 0.10.1", + "ethereum_ssz", + "ethereum_ssz_derive", + "sha2 0.10.9", "snap", "thiserror 2.0.18", ] [[package]] name = "reth-era-downloader" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "bytes", "eyre", "futures-util", - "reqwest", + "reqwest 0.13.3", "reth-era", "reth-fs-util", "sha2 0.10.9", @@ -10579,8 +10756,8 @@ dependencies = [ [[package]] name = "reth-era-utils" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -10601,8 +10778,8 @@ dependencies = [ [[package]] name = "reth-errors" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "reth-consensus", "reth-execution-errors", @@ -10612,8 +10789,8 @@ dependencies = [ [[package]] name = "reth-eth-wire" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-chains", "alloy-primitives", @@ -10641,12 +10818,13 @@ dependencies = [ [[package]] name = "reth-eth-wire-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-chains", "alloy-consensus", - "alloy-eips", + "alloy-eip7928", + "alloy-eips 2.0.4", "alloy-hardforks", "alloy-primitives", "alloy-rlp", @@ -10665,8 +10843,8 @@ dependencies = [ [[package]] name = "reth-ethereum" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-rpc-types-engine", "alloy-rpc-types-eth", @@ -10705,8 +10883,8 @@ dependencies = [ [[package]] name = "reth-ethereum-cli" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "clap", "eyre", @@ -10728,11 +10906,11 @@ dependencies = [ [[package]] name = "reth-ethereum-consensus" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "reth-chainspec", "reth-consensus", @@ -10744,26 +10922,24 @@ dependencies = [ [[package]] name = "reth-ethereum-engine-primitives" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", - "alloy-rlp", "alloy-rpc-types-engine", "reth-engine-primitives", "reth-ethereum-primitives", "reth-payload-primitives", "reth-primitives-traits", "serde", - "sha2 0.10.9", "thiserror 2.0.18", ] [[package]] name = "reth-ethereum-forks" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-eip2124", "alloy-hardforks", @@ -10776,11 +10952,11 @@ dependencies = [ [[package]] name = "reth-ethereum-payload-builder" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", @@ -10791,6 +10967,7 @@ dependencies = [ "reth-ethereum-primitives", "reth-evm", "reth-evm-ethereum", + "reth-execution-cache", "reth-payload-builder", "reth-payload-builder-primitives", "reth-payload-primitives", @@ -10805,28 +10982,22 @@ dependencies = [ [[package]] name = "reth-ethereum-primitives" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", - "alloy-rlp", "alloy-rpc-types-eth", - "alloy-serde", - "arbitrary", - "modular-bitfield", "reth-codecs", "reth-primitives-traits", - "reth-zstd-compressors", "serde", - "serde_with", ] [[package]] name = "reth-etl" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "rayon", "reth-db-api", @@ -10835,11 +11006,11 @@ dependencies = [ [[package]] name = "reth-evm" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-evm", "alloy-primitives", "auto_impl", @@ -10859,16 +11030,14 @@ dependencies = [ [[package]] name = "reth-evm-ethereum" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-evm", "alloy-primitives", "alloy-rpc-types-engine", - "derive_more", - "parking_lot", "reth-chainspec", "reth-ethereum-forks", "reth-ethereum-primitives", @@ -10879,10 +11048,28 @@ dependencies = [ "revm", ] +[[package]] +name = "reth-execution-cache" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" +dependencies = [ + "alloy-primitives", + "fixed-cache", + "metrics", + "parking_lot", + "reth-errors", + "reth-metrics", + "reth-primitives-traits", + "reth-provider", + "reth-revm", + "reth-trie", + "tracing", +] + [[package]] name = "reth-execution-errors" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-evm", "alloy-primitives", @@ -10894,13 +11081,14 @@ dependencies = [ [[package]] name = "reth-execution-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-evm", "alloy-primitives", + "alloy-rlp", "derive_more", "reth-ethereum-primitives", "reth-primitives-traits", @@ -10912,11 +11100,11 @@ dependencies = [ [[package]] name = "reth-exex" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "eyre", "futures", @@ -10950,10 +11138,10 @@ dependencies = [ [[package]] name = "reth-exex-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "reth-chain-state", "reth-execution-types", @@ -10964,8 +11152,8 @@ dependencies = [ [[package]] name = "reth-fs-util" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "serde", "serde_json", @@ -10974,8 +11162,8 @@ dependencies = [ [[package]] name = "reth-invalid-block-hooks" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -11002,8 +11190,8 @@ dependencies = [ [[package]] name = "reth-ipc" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "bytes", "futures", @@ -11022,11 +11210,12 @@ dependencies = [ [[package]] name = "reth-libmdbx" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "bitflags 2.11.0", "byteorder", + "crossbeam-queue", "dashmap", "derive_more", "parking_lot", @@ -11038,8 +11227,8 @@ dependencies = [ [[package]] name = "reth-mdbx-sys" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "bindgen", "cc", @@ -11047,20 +11236,21 @@ dependencies = [ [[package]] name = "reth-metrics" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "futures", "metrics", "metrics-derive", + "reth-primitives-traits", "tokio", "tokio-util", ] [[package]] name = "reth-net-banlist" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "ipnet", @@ -11068,12 +11258,12 @@ dependencies = [ [[package]] name = "reth-net-nat" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "futures-util", "if-addrs 0.14.0", - "reqwest", + "reqwest 0.13.3", "serde_with", "thiserror 2.0.18", "tokio", @@ -11082,11 +11272,11 @@ dependencies = [ [[package]] name = "reth-network" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", "aquamarine", @@ -11130,6 +11320,7 @@ dependencies = [ "secp256k1 0.30.0", "serde", "smallvec", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -11139,8 +11330,8 @@ dependencies = [ [[package]] name = "reth-network-api" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -11164,11 +11355,11 @@ dependencies = [ [[package]] name = "reth-network-p2p" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "auto_impl", "derive_more", @@ -11187,8 +11378,8 @@ dependencies = [ [[package]] name = "reth-network-peers" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -11202,8 +11393,8 @@ dependencies = [ [[package]] name = "reth-network-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-eip2124", "humantime-serde", @@ -11216,8 +11407,8 @@ dependencies = [ [[package]] name = "reth-nippy-jar" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "anyhow", "bincode", @@ -11233,8 +11424,8 @@ dependencies = [ [[package]] name = "reth-node-api" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-rpc-types-engine", "eyre", @@ -11257,11 +11448,11 @@ dependencies = [ [[package]] name = "reth-node-builder" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-provider", "alloy-rpc-types", @@ -11285,7 +11476,6 @@ dependencies = [ "reth-downloaders", "reth-engine-local", "reth-engine-primitives", - "reth-engine-service", "reth-engine-tree", "reth-engine-util", "reth-evm", @@ -11326,11 +11516,11 @@ dependencies = [ [[package]] name = "reth-node-core" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rpc-types-engine", "clap", @@ -11364,12 +11554,12 @@ dependencies = [ "reth-stages-types", "reth-storage-api", "reth-storage-errors", + "reth-tasks", "reth-tracing", "reth-tracing-otlp", "reth-transaction-pool", "secp256k1 0.30.0", "serde", - "shellexpand", "strum 0.27.2", "thiserror 2.0.18", "toml 0.9.12+spec-1.1.0", @@ -11381,10 +11571,10 @@ dependencies = [ [[package]] name = "reth-node-ethereum" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-network", "alloy-rpc-types-engine", "alloy-rpc-types-eth", @@ -11419,8 +11609,8 @@ dependencies = [ [[package]] name = "reth-node-ethstats" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -11443,11 +11633,11 @@ dependencies = [ [[package]] name = "reth-node-events" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rpc-types-engine", "derive_more", @@ -11467,8 +11657,8 @@ dependencies = [ [[package]] name = "reth-node-metrics" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "bytes", "eyre", @@ -11480,7 +11670,7 @@ dependencies = [ "metrics-process", "metrics-util", "procfs 0.18.0", - "reqwest", + "reqwest 0.13.3", "reth-metrics", "reth-tasks", "tikv-jemalloc-ctl", @@ -11491,8 +11681,8 @@ dependencies = [ [[package]] name = "reth-node-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "reth-chainspec", "reth-db-api", @@ -11503,20 +11693,23 @@ dependencies = [ [[package]] name = "reth-payload-builder" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-primitives", "alloy-rpc-types", + "derive_more", "futures-util", "metrics", "reth-chain-state", "reth-ethereum-engine-primitives", + "reth-execution-cache", "reth-metrics", "reth-payload-builder-primitives", "reth-payload-primitives", "reth-primitives-traits", + "reth-trie-parallel", "tokio", "tokio-stream", "tracing", @@ -11524,8 +11717,8 @@ dependencies = [ [[package]] name = "reth-payload-builder-primitives" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "pin-project", "reth-payload-primitives", @@ -11536,16 +11729,16 @@ dependencies = [ [[package]] name = "reth-payload-primitives" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", + "alloy-rlp", "alloy-rpc-types-engine", "auto_impl", "either", - "op-alloy-rpc-types-engine", "reth-chain-state", "reth-chainspec", "reth-errors", @@ -11553,56 +11746,44 @@ dependencies = [ "reth-primitives-traits", "reth-trie-common", "serde", + "sha2 0.10.9", "thiserror 2.0.18", "tokio", ] [[package]] name = "reth-payload-validator" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-rpc-types-engine", "reth-primitives-traits", ] -[[package]] -name = "reth-primitives" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" -dependencies = [ - "alloy-consensus", - "once_cell", - "reth-ethereum-forks", - "reth-ethereum-primitives", - "reth-primitives-traits", - "reth-static-file-types", -] - [[package]] name = "reth-primitives-traits" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee12e304adbacbb32248c9806ebafbe1e2811fbfefe53c5e5b710a8438b7ec0" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-genesis", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-eth", "alloy-trie", "arbitrary", - "auto_impl", "byteorder", "bytes", "dashmap", "derive_more", "modular-bitfield", "once_cell", - "op-alloy-consensus", "proptest", "proptest-arbitrary-interop", + "quanta", "rayon", "reth-codecs", "revm-bytecode", @@ -11610,17 +11791,17 @@ dependencies = [ "revm-state", "secp256k1 0.30.0", "serde", - "serde_with", "thiserror 2.0.18", ] [[package]] name = "reth-provider" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", + "alloy-genesis", "alloy-primitives", "alloy-rpc-types-engine", "eyre", @@ -11653,6 +11834,7 @@ dependencies = [ "reth-trie-db", "revm-database", "revm-state", + "rocksdb", "strum 0.27.2", "tokio", "tracing", @@ -11660,11 +11842,11 @@ dependencies = [ [[package]] name = "reth-prune" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "itertools 0.14.0", "metrics", @@ -11689,8 +11871,8 @@ dependencies = [ [[package]] name = "reth-prune-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "arbitrary", @@ -11705,10 +11887,12 @@ dependencies = [ [[package]] name = "reth-revm" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-debug", "reth-primitives-traits", "reth-storage-api", "reth-storage-errors", @@ -11718,13 +11902,12 @@ dependencies = [ [[package]] name = "reth-rpc" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-dyn-abi", - "alloy-eip7928", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-evm", "alloy-genesis", "alloy-network", @@ -11740,7 +11923,7 @@ dependencies = [ "alloy-rpc-types-mev", "alloy-rpc-types-trace", "alloy-rpc-types-txpool", - "alloy-serde", + "alloy-serde 2.0.4", "alloy-signer", "alloy-signer-local", "async-trait", @@ -11778,6 +11961,7 @@ dependencies = [ "reth-rpc-server-types", "reth-storage-api", "reth-tasks", + "reth-tracing", "reth-transaction-pool", "reth-trie-common", "revm", @@ -11795,11 +11979,10 @@ dependencies = [ [[package]] name = "reth-rpc-api" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eip7928", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-genesis", "alloy-json-rpc", "alloy-primitives", @@ -11813,20 +11996,21 @@ dependencies = [ "alloy-rpc-types-mev", "alloy-rpc-types-trace", "alloy-rpc-types-txpool", - "alloy-serde", + "alloy-serde 2.0.4", "jsonrpsee", "reth-chain-state", "reth-engine-primitives", "reth-network-peers", "reth-rpc-eth-api", "reth-trie-common", + "serde", "serde_json", ] [[package]] name = "reth-rpc-builder" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-network", "alloy-provider", @@ -11844,9 +12028,11 @@ dependencies = [ "reth-metrics", "reth-network-api", "reth-node-core", + "reth-payload-primitives", "reth-primitives-traits", "reth-rpc", "reth-rpc-api", + "reth-rpc-engine-api", "reth-rpc-eth-api", "reth-rpc-eth-types", "reth-rpc-layer", @@ -11866,8 +12052,8 @@ dependencies = [ [[package]] name = "reth-rpc-convert" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-evm", @@ -11875,23 +12061,23 @@ dependencies = [ "alloy-network", "alloy-primitives", "alloy-rpc-types-eth", - "alloy-signer", "auto_impl", "dyn-clone", "jsonrpsee-types", - "reth-ethereum-primitives", "reth-evm", "reth-primitives-traits", + "reth-rpc-traits", "thiserror 2.0.18", ] [[package]] name = "reth-rpc-engine-api" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", + "alloy-rlp", "alloy-rpc-types-engine", "async-trait", "jsonrpsee-core", @@ -11917,12 +12103,13 @@ dependencies = [ [[package]] name = "reth-rpc-eth-api" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-dyn-abi", - "alloy-eips", + "alloy-eip7928", + "alloy-eips 2.0.4", "alloy-evm", "alloy-json-rpc", "alloy-network", @@ -11930,7 +12117,7 @@ dependencies = [ "alloy-rlp", "alloy-rpc-types-eth", "alloy-rpc-types-mev", - "alloy-serde", + "alloy-serde 2.0.4", "async-trait", "auto_impl", "dyn-clone", @@ -11955,17 +12142,18 @@ dependencies = [ "reth-trie-common", "revm", "revm-inspectors", + "serde_json", "tokio", "tracing", ] [[package]] name = "reth-rpc-eth-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-evm", "alloy-network", "alloy-primitives", @@ -11980,7 +12168,7 @@ dependencies = [ "jsonrpsee-types", "metrics", "rand 0.9.4", - "reqwest", + "reqwest 0.13.3", "reth-chain-state", "reth-chainspec", "reth-errors", @@ -12009,8 +12197,8 @@ dependencies = [ [[package]] name = "reth-rpc-layer" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-rpc-types-engine", "http", @@ -12023,10 +12211,10 @@ dependencies = [ [[package]] name = "reth-rpc-server-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rpc-types-engine", "jsonrpsee-core", @@ -12037,21 +12225,37 @@ dependencies = [ "strum 0.27.2", ] +[[package]] +name = "reth-rpc-traits" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "860fe223501a76ff14aa3bf164f739f31008c2a2905ac85708bfd88f042e6151" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-signer", + "reth-primitives-traits", + "thiserror 2.0.18", +] + [[package]] name = "reth-stages" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", - "bincode", + "alloy-rlp", "eyre", "futures-util", "itertools 0.14.0", "num-traits", + "page_size", "rayon", - "reqwest", + "reqwest 0.13.3", "reth-chainspec", "reth-codecs", "reth-config", @@ -12067,6 +12271,7 @@ dependencies = [ "reth-execution-types", "reth-exex", "reth-fs-util", + "reth-libmdbx", "reth-network-p2p", "reth-primitives-traits", "reth-provider", @@ -12089,15 +12294,16 @@ dependencies = [ [[package]] name = "reth-stages-api" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "aquamarine", "auto_impl", "futures-util", "metrics", + "reth-codecs", "reth-consensus", "reth-errors", "reth-metrics", @@ -12116,8 +12322,8 @@ dependencies = [ [[package]] name = "reth-stages-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "arbitrary", @@ -12130,8 +12336,8 @@ dependencies = [ [[package]] name = "reth-static-file" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "parking_lot", @@ -12150,8 +12356,8 @@ dependencies = [ [[package]] name = "reth-static-file-types" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "clap", @@ -12165,11 +12371,11 @@ dependencies = [ [[package]] name = "reth-storage-api" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rpc-types-engine", "auto_impl", @@ -12189,13 +12395,14 @@ dependencies = [ [[package]] name = "reth-storage-errors" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", "derive_more", + "reth-codecs", "reth-primitives-traits", "reth-prune-types", "reth-static-file-types", @@ -12206,17 +12413,20 @@ dependencies = [ [[package]] name = "reth-tasks" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ - "auto_impl", - "dyn-clone", + "crossbeam-utils", + "dashmap", "futures-util", + "libc", "metrics", + "parking_lot", "pin-project", "rayon", "reth-metrics", "thiserror 2.0.18", + "thread-priority", "tokio", "tracing", "tracing-futures", @@ -12224,11 +12434,11 @@ dependencies = [ [[package]] name = "reth-testing-utils" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-genesis", "alloy-primitives", "rand 0.8.5", @@ -12240,8 +12450,8 @@ dependencies = [ [[package]] name = "reth-tokio-util" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "tokio", "tokio-stream", @@ -12250,8 +12460,8 @@ dependencies = [ [[package]] name = "reth-tracing" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "clap", "eyre", @@ -12267,8 +12477,8 @@ dependencies = [ [[package]] name = "reth-tracing-otlp" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "clap", "eyre", @@ -12284,17 +12494,18 @@ dependencies = [ [[package]] name = "reth-transaction-pool" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", "aquamarine", "auto_impl", "bitflags 2.11.0", "futures-util", + "imbl", "metrics", "parking_lot", "paste", @@ -12330,11 +12541,11 @@ dependencies = [ [[package]] name = "reth-trie" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rlp", "alloy-trie", @@ -12356,14 +12567,14 @@ dependencies = [ [[package]] name = "reth-trie-common" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-consensus", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-eth", - "alloy-serde", + "alloy-serde 2.0.4", "alloy-trie", "arbitrary", "arrayvec", @@ -12383,8 +12594,8 @@ dependencies = [ [[package]] name = "reth-trie-db" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "metrics", @@ -12403,12 +12614,15 @@ dependencies = [ [[package]] name = "reth-trie-parallel" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ + "alloy-eip7928", + "alloy-evm", "alloy-primitives", "alloy-rlp", "crossbeam-channel", + "crossbeam-utils", "derive_more", "itertools 0.14.0", "metrics", @@ -12420,44 +12634,47 @@ dependencies = [ "reth-storage-errors", "reth-tasks", "reth-trie", - "reth-trie-common", "reth-trie-sparse", + "revm-state", "thiserror 2.0.18", "tracing", ] [[package]] name = "reth-trie-sparse" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "2.2.0" +source = "git+https://github.com/paradigmxyz/reth?tag=v2.2.0#88505c7fcbfdebfd3b56d88c86b62e950043c6c4" dependencies = [ "alloy-primitives", "alloy-rlp", "alloy-trie", - "auto_impl", "metrics", "rayon", "reth-execution-errors", "reth-metrics", "reth-primitives-traits", "reth-trie-common", + "serde", + "serde_json", + "slotmap", "smallvec", "tracing", ] [[package]] name = "reth-zstd-compressors" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.3#d6324d63e27ef6b7c49cdc9b1977c1b808234c7b" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12fafa33d2f420a9d39249a3e0357b1928d09429f30758b85280409092873b2" dependencies = [ "zstd", ] [[package]] name = "revm" -version = "34.0.0" +version = "38.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2aabdebaa535b3575231a88d72b642897ae8106cf6b0d12eafc6bfdf50abfc7" +checksum = "91202d39dbe8e8d10e9e8f2b76c30da68ecd1d25be69ba6d853ad0d03a3a398a" dependencies = [ "revm-bytecode", "revm-context", @@ -12474,9 +12691,9 @@ dependencies = [ [[package]] name = "revm-bytecode" -version = "8.0.0" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d1e5c1eaa44d39d537f668bc5c3409dc01e5c8be954da6c83370bbdf006457" +checksum = "bdbb3a3d735efa94c91f2ef6bf20a35f99a77bc78f3e25bd758336901bdf9661" dependencies = [ "bitvec", "phf", @@ -12486,9 +12703,9 @@ dependencies = [ [[package]] name = "revm-context" -version = "13.0.0" +version = "16.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "892ff3e6a566cf8d72ffb627fdced3becebbd9ba64089c25975b9b028af326a5" +checksum = "c5f68d928d8b228e0faeb1c6ed75c4fde7d124f1ddf9119b67e7a0ad4041237d" dependencies = [ "bitvec", "cfg-if", @@ -12503,9 +12720,9 @@ dependencies = [ [[package]] name = "revm-context-interface" -version = "14.0.0" +version = "17.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f61cc6d23678c4840af895b19f8acfbbd546142ec8028b6526c53cc1c16c98" +checksum = "1f3758e6167c4ba7a59a689c519a047edaefcd4c37d74f279b93ed87bc8aece4" dependencies = [ "alloy-eip2930", "alloy-eip7702", @@ -12519,11 +12736,11 @@ dependencies = [ [[package]] name = "revm-database" -version = "10.0.0" +version = "13.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529528d0b05fe646be86223032c3e77aa8b05caa2a35447d538c55965956a511" +checksum = "c281a1f11d3bcb8c0bba1199ed6bcb001d1aeb3d4fb366819e14f88723989a4e" dependencies = [ - "alloy-eips", + "alloy-eips 1.7.3", "revm-bytecode", "revm-database-interface", "revm-primitives", @@ -12533,9 +12750,9 @@ dependencies = [ [[package]] name = "revm-database-interface" -version = "9.0.0" +version = "11.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7bf93ac5b91347c057610c0d96e923db8c62807e03f036762d03e981feddc1d" +checksum = "d89efb9832a4e3742bb4ded5f7fe5bf905e8860e69427d4dfec153484fc6d304" dependencies = [ "auto_impl", "either", @@ -12547,9 +12764,9 @@ dependencies = [ [[package]] name = "revm-handler" -version = "15.0.0" +version = "18.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cd0e43e815a85eded249df886c4badec869195e70cdd808a13cfca2794622d2" +checksum = "783e903d6922b7f5f9a940d1bb229530502d2924b1aed9d5ca5a94ebf065d460" dependencies = [ "auto_impl", "derive-where", @@ -12566,9 +12783,9 @@ dependencies = [ [[package]] name = "revm-inspector" -version = "15.0.0" +version = "19.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3ccad59db91ef93696536a0dbaf2f6f17cfe20d4d8843ae118edb7e97947ef" +checksum = "8216ad58422090d0daa9eb430e0a081f7ad07e7fd30681dee71f8420c99624e0" dependencies = [ "auto_impl", "either", @@ -12584,9 +12801,9 @@ dependencies = [ [[package]] name = "revm-inspectors" -version = "0.34.2" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e435414e9de50a1b930da602067c76365fea2fea11e80ceb50783c94ddd127f" +checksum = "731b682530a732ef9c189ef831589128e2ce34d4a306c956322ae2dffe009715" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", @@ -12604,9 +12821,9 @@ dependencies = [ [[package]] name = "revm-interpreter" -version = "32.0.0" +version = "35.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11406408597bc249392d39295831c4b641b3a6f5c471a7c41104a7a1e3564c07" +checksum = "1ece9f41b69658c15d748288a4dbdfc06a63f3ce93d983af440de3f1631dce6a" dependencies = [ "revm-bytecode", "revm-context-interface", @@ -12617,9 +12834,9 @@ dependencies = [ [[package]] name = "revm-precompile" -version = "32.0.0" +version = "34.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c1285c848d240678bf69cb0f6179ff5a4aee6fc8e921d89708087197a0aff3" +checksum = "a346a8cc6c8c39bd65306641c692191299c0a7b63d38810e39e8fe9b92378660" dependencies = [ "ark-bls12-381", "ark-bn254", @@ -12628,11 +12845,13 @@ dependencies = [ "ark-serialize 0.5.0", "arrayref", "aurora-engine-modexp", + "aws-lc-rs", "blst", "c-kzg", "cfg-if", "k256", "p256", + "revm-context-interface", "revm-primitives", "ripemd", "secp256k1 0.31.1", @@ -12641,9 +12860,9 @@ dependencies = [ [[package]] name = "revm-primitives" -version = "22.0.0" +version = "23.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba580c56a8ec824a64f8a1683577876c2e1dbe5247044199e9b881421ad5dcf9" +checksum = "0c99bda77d9661521ba0b4bc04558c6692074f01e65dd420fa3b893033d9b8a2" dependencies = [ "alloy-primitives", "num_enum", @@ -12653,9 +12872,9 @@ dependencies = [ [[package]] name = "revm-state" -version = "9.0.0" +version = "11.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311720d4f0f239b041375e7ddafdbd20032a33b7bae718562ea188e188ed9fd3" +checksum = "c32490ed687dba31c3c882beb8c20408bdd30ef96690d8f145b0ee9a87040bfe" dependencies = [ "alloy-eip7928", "bitflags 2.11.0", @@ -12666,13 +12885,12 @@ dependencies = [ [[package]] name = "revm-statetest-types" -version = "14.0.0" +version = "17.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fab40862cebf520cf8f67dcd6a81dc8207140e57b36a9f15712e0dc7974a036d" +checksum = "94743de1e54a812077b79dd3a87b4059175a804650b21582fb6feae96ef2be9c" dependencies = [ "alloy-eip7928", "k256", - "revm-bytecode", "revm-context", "revm-context-interface", "revm-database", @@ -12703,7 +12921,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -12743,40 +12961,31 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.16.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" +checksum = "f542f74cf247da16f19bbc87e298cd201e912314f4083e88cdd671f44f5fcb53" dependencies = [ "async-trait", "base64 0.22.1", - "bytes", "chrono", "futures", - "http", - "http-body", - "http-body-util", "pastey", "pin-project-lite", - "rand 0.10.1", "rmcp-macros", "schemars 1.2.1", "serde", "serde_json", - "sse-stream", "thiserror 2.0.18", "tokio", - "tokio-stream", "tokio-util", - "tower-service", "tracing", - "uuid", ] [[package]] name = "rmcp-macros" -version = "0.16.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90c23c8f26cae4da838fbc3eadfaecf2d549d97c04b558e7bd90526a9c28b42a" +checksum = "b2391e4ae47f314e70eaafb6c7bd82e495e770b935448864446302143019151f" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -12806,14 +13015,24 @@ dependencies = [ [[package]] name = "roaring" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ba9ce64a8f45d7fc86358410bb1a82e8c987504c0d4900e9141d69a9f26c885" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" dependencies = [ "bytemuck", "byteorder", ] +[[package]] +name = "rocksdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" +dependencies = [ + "libc", + "librocksdb-sys", +] + [[package]] name = "rolling-file" version = "0.2.0" @@ -12879,15 +13098,16 @@ dependencies = [ [[package]] name = "ruint" -version = "1.17.2" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" dependencies = [ "alloy-rlp", "arbitrary", "ark-ff 0.3.0", "ark-ff 0.4.2", "ark-ff 0.5.0", + "ark-ff 0.6.0", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", @@ -13001,6 +13221,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -13049,7 +13270,7 @@ checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -13062,6 +13283,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 1.0.6", + "windows-sys 0.59.0", +] + [[package]] name = "rustls-platform-verifier-android" version = "0.1.1" @@ -13074,9 +13316,10 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -13120,6 +13363,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -13614,15 +13866,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shellexpand" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" -dependencies = [ - "dirs", -] - [[package]] name = "shlex" version = "1.3.0" @@ -13685,6 +13928,22 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -13734,6 +13993,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "small_btree" version = "0.1.0" @@ -13799,9 +14067,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.60.2", @@ -13828,7 +14096,7 @@ name = "spammer" version = "0.0.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.4", "alloy-primitives", "alloy-rpc-types-txpool", "alloy-signer", @@ -13849,6 +14117,7 @@ dependencies = [ "serde_json", "strum 0.27.2", "strum_macros 0.27.2", + "tempfile", "tokio", "tokio-tungstenite 0.28.0", "tracing", @@ -13895,19 +14164,6 @@ dependencies = [ "der 0.8.0", ] -[[package]] -name = "sse-stream" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" -dependencies = [ - "bytes", - "futures-util", - "http-body", - "http-body-util", - "pin-project-lite", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -14187,6 +14443,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread-priority" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2210811179577da3d54eb69ab0b50490ee40491a25d95b8c6011ba40771cb721" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.61.3", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -14308,9 +14578,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -14318,7 +14588,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -14326,9 +14596,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -14381,7 +14651,6 @@ dependencies = [ "tokio", "tokio-rustls", "tungstenite 0.26.2", - "webpki-roots 0.26.11", ] [[package]] @@ -14392,8 +14661,13 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.28.0", + "webpki-roots 0.26.11", ] [[package]] @@ -14783,9 +15057,9 @@ dependencies = [ [[package]] name = "tracing-logfmt" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1f47d22deb79c3f59fcf2a1f00f60cbdc05462bf17d1cd356c1fefa3f444bd" +checksum = "a250055a3518b5efba928a18ffac8d32d42ea607a9affff4532144cd5b2e378e" dependencies = [ "time", "tracing", @@ -14879,24 +15153,24 @@ dependencies = [ [[package]] name = "tree_hash" -version = "0.10.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee44f4cef85f88b4dea21c0b1f58320bdf35715cf56d840969487cff00613321" +checksum = "f7fd51aa83d2eb83b04570808430808b5d24fdbf479a4d5ac5dee4a2e2dd2be4" dependencies = [ "alloy-primitives", "ethereum_hashing", - "ethereum_ssz 0.9.1", + "ethereum_ssz", "smallvec", "typenum", ] [[package]] name = "tree_hash_derive" -version = "0.10.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bee2ea1551f90040ab0e34b6fb7f2fa3bad8acc925837ac654f2c78a13e3089" +checksum = "8840ad4d852e325d3afa7fde8a50b2412f89dce47d7eb291c0cc7f87cd040f38" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -14949,6 +15223,8 @@ dependencies = [ "httparse", "log", "rand 0.9.4", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.18", "utf-8", @@ -15085,6 +15361,12 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -15344,9 +15626,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -15437,6 +15719,16 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "widestring" version = "1.2.1" @@ -15465,7 +15757,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -15484,16 +15776,38 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", + "windows-collections 0.3.2", "windows-core 0.62.2", - "windows-future", - "windows-numerics", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -15515,6 +15829,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -15523,9 +15850,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.1", "windows-result 0.4.1", - "windows-strings", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -15535,8 +15873,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core 0.62.2", - "windows-link", - "windows-threading", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -15561,12 +15899,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -15574,7 +15928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core 0.62.2", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -15586,13 +15940,31 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -15601,7 +15973,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -15655,7 +16027,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -15710,7 +16082,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -15721,13 +16093,22 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f4e7ac89..6d50898f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ edition = "2024" readme = "README.md" license = "Apache-2.0" exclude = [".github/"] -rust-version = "1.91" +rust-version = "1.93" publish = false repository = "https://github.com/circlefin/arc-node" @@ -31,29 +31,31 @@ unwrap_used = "deny" [workspace.dependencies] # eth -alloy-consensus = { version = "1.6.3", default-features = false } -alloy-eips = { version = "1.6.3", default-features = false } -alloy-evm = { version = "0.27.2", default-features = false } -alloy-genesis = { version = "1.6.3", default-features = false } +alloy-consensus = { version = "2.0.4", default-features = false } +alloy-eips = { version = "2.0.4", default-features = false } +alloy-evm = { version = "0.34.0", default-features = false } +alloy-genesis = { version = "2.0.4", default-features = false } -alloy-network = { version = "1.6.3", default-features = false } +alloy-network = { version = "2.0.4", default-features = false } # op alloy-primitives = { version = "1.5.6", default-features = false, features = ["map-foldhash", "keccak-cache-global", "asm-keccak"] } -alloy-provider = { version = "1.6.3", features = ["reqwest"], default-features = false } +alloy-provider = { version = "2.0.4", features = ["reqwest"], default-features = false } alloy-rlp = { version = "0.3.13", default-features = false, features = ["core-net"] } -alloy-rpc-types = { version = "1.6.3", features = ["eth"], default-features = false } -alloy-rpc-types-admin = { version = "1.6.3", default-features = false } -alloy-rpc-types-engine = { version = "1.6.3", default-features = false } -alloy-rpc-types-eth = { version = "1.6.3", default-features = false } -alloy-rpc-types-trace = { version = "1.6.3", default-features = false } -alloy-rpc-types-txpool = { version = "1.6.3", default-features = false } -alloy-serde = { version = "1.6.3", default-features = false } -alloy-signer = { version = "1.6.3", default-features = false } -alloy-signer-local = { version = "1.6.3", default-features = false, features = ["mnemonic"] } +alloy-rpc-client = { version = "2.0.4", default-features = false, features = ["reqwest"] } +alloy-rpc-types = { version = "2.0.4", features = ["eth"], default-features = false } +alloy-rpc-types-admin = { version = "2.0.4", default-features = false } +alloy-rpc-types-engine = { version = "2.0.4", default-features = false } +alloy-rpc-types-eth = { version = "2.0.4", default-features = false } +alloy-rpc-types-trace = { version = "2.0.4", default-features = false } +alloy-rpc-types-txpool = { version = "2.0.4", default-features = false } +alloy-serde = { version = "2.0.4", default-features = false } +alloy-signer = { version = "2.0.4", default-features = false } +alloy-signer-local = { version = "2.0.4", default-features = false, features = ["mnemonic"] } alloy-sol-macro = "1.5.6" alloy-sol-types = { version = "1.5.6", default-features = false } -alloy-transport-ws = { version = "1.6.3", default-features = false } +alloy-transport = { version = "2.0.4", default-features = false } +alloy-transport-ws = { version = "2.0.4", default-features = false } alloy-trie = { version = "0.9.4", default-features = false } arbitrary = "1.3" @@ -65,6 +67,7 @@ arc-consensus-types = { version = "0.0.1", path = "crates/types" } arc-eth-engine = { version = "0.0.1", path = "crates/eth-engine" } arc-evm = { version = "0.0.1", path = "crates/evm" } arc-evm-node = { version = "0.0.1", path = "crates/evm-node" } +arc-evm-specs-tests = { version = "0.1.0", path = "crates/evm-specs-tests" } arc-execution-config = { version = "0.0.1", path = "crates/execution-config" } arc-execution-payload = { version = "0.0.1", path = "crates/execution-payload" } arc-execution-txpool = { version = "0.0.1", path = "crates/execution-txpool" } @@ -73,6 +76,7 @@ arc-mesh-analysis = { version = "0.0.1", path = "crates/mesh-analysis" } arc-node-consensus = { version = "0.0.1", path = "crates/malachite-app" } arc-node-consensus-cli = { version = "0.0.1", path = "crates/malachite-cli" } arc-node-execution = { version = "0.0.1", path = "crates/node" } +arc-pq-precompile = { version = "0.0.1", path = "crates/pq-precompile" } arc-precompiles = { version = "0.0.1", path = "crates/precompiles" } arc-remote-signer = { version = "0.0.1", path = "crates/remote-signer" } arc-shared = { version = "0.0.1", path = "crates/shared" } @@ -120,8 +124,8 @@ ed25519-dalek = "2.0" # crypto # ssz encoding -ethereum_ssz = "0.9.0" -ethereum_ssz_derive = "0.9.0" +ethereum_ssz = "0.10.1" +ethereum_ssz_derive = "0.10.1" eyre = "0.6" futures = "0.3" futures-util = { version = "0.3", default-features = false } @@ -166,62 +170,70 @@ ractor = { version = "0.15.10", default-features = false, features = ["async-tra rand = { version = "0.8.5", features = ["std_rng", "small_rng"] } # rand 8 for secp256k1 redb = "2.6.0" -reqwest = { version = "0.12", default-features = false } -reth-basic-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-chainspec = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-cli = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-cli-commands = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-consensus = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-consensus-common = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-db = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-e2e-test-utils = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-engine-local = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-engine-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-errors = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-ethereum-engine-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-ethereum-forks = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-ethereum-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-ethereum-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-evm = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-evm-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-ipc = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-network = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-network-peers = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-node-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-node-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-node-core = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-node-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-node-metrics = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-payload-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-primitives-traits = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-provider = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-prune-types = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-revm = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-rpc = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-rpc-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-rpc-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-rpc-eth-types = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-rpc-server-types = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-storage-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3", default-features = false } -reth-tasks = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-tracing = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } -reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.3" } +reqwest = { version = "0.13", default-features = false } +reth-basic-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-chainspec = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-cli = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-cli-commands = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-codecs = { version = "0.3.1" } +reth-consensus = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-consensus-common = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-db = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-db-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-e2e-test-utils = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-engine-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-errors = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-ethereum-engine-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-ethereum-forks = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-ethereum-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-ethereum-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-evm = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-evm-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-execution-cache = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-fs-util = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-ipc = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-network = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-network-peers = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-node-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-node-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-node-core = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-node-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-node-metrics = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-payload-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-primitives-traits = { version = "0.3.1", default-features = false } +reth-provider = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-prune-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-revm = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-rpc = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-eth-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-rpc-server-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-stages-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-static-file = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-static-file-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-storage-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-tasks = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-tracing = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-trie-parallel = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } # revm -revm = { version = "34.0.0", default-features = false } -revm-bytecode = { version = "8.0.0", default-features = false } -revm-context-interface = { version = "14.0.0", default-features = false } -revm-database = { version = "10.0.0", default-features = false } -revm-inspector = { version = "15.0.0", default-features = false } -revm-inspectors = "0.34.2" -revm-interpreter = { version = "32.0.0", default-features = false } -revm-primitives = { version = "22.0.0", default-features = false } -revm-statetest-types = { version = "14.0.0", default-features = false } - -rmcp = { version = "0.16", features = ["server", "macros", "transport-io", "transport-streamable-http-server"] } +revm = { version = "38.0.0", default-features = false } +revm-bytecode = { version = "10.0.0", default-features = false } +revm-context-interface = { version = "17.0.1", default-features = false } +revm-database = { version = "13.0.1", default-features = false } +revm-handler = { version = "18.1.0", default-features = false } +revm-inspector = { version = "19.0.0", default-features = false } +revm-inspectors = "0.39.0" +revm-interpreter = { version = "35.0.1", default-features = false } +revm-primitives = { version = "23.0.0", default-features = false } +revm-statetest-types = { version = "17.0.1", default-features = false } + +rmcp = { version = "1.4", features = ["server", "macros", "transport-io"] } rstest = "0.24.0" schemars = "1.0" schnellru = "0.2" @@ -257,6 +269,7 @@ toml = "0.8" tonic = "0.12" tonic-build = "0.12" tower = "0.5" +tower-http = { version = "0.6", default-features = false, features = ["compression-gzip", "timeout"] } tracing = { version = "0.1.0", default-features = false } tracing-appender = "0.2" tracing-subscriber = { version = "0.3", default-features = false, features = ["ansi"] } @@ -269,67 +282,72 @@ wiremock = "0.6" [workspace.dependencies.malachitebft-app] package = "arc-malachitebft-app" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-app-channel] package = "arc-malachitebft-app-channel" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-codec] package = "arc-malachitebft-codec" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-config] package = "arc-malachitebft-config" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-core-consensus] package = "arc-malachitebft-core-consensus" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-core-state-machine] package = "arc-malachitebft-core-state-machine" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-core-types] package = "arc-malachitebft-core-types" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" + +[workspace.dependencies.malachitebft-engine-byzantine] +package = "arc-malachitebft-engine-byzantine" +git = "https://github.com/circlefin/malachite.git" +tag = "v0.8.0" [workspace.dependencies.malachitebft-network] package = "arc-malachitebft-network" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-peer] package = "arc-malachitebft-peer" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-proto] package = "arc-malachitebft-proto" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-signing] package = "arc-malachitebft-signing" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-signing-ed25519] package = "arc-malachitebft-signing-ed25519" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [workspace.dependencies.malachitebft-sync] package = "arc-malachitebft-sync" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +tag = "v0.8.0" [profile.dev] # enable basic optimizations (otherwise reth can't process txs) diff --git a/Makefile b/Makefile index f7c48922..8faa3a35 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ check-foundry: ## Check Foundry version .PHONY: fmt fmt: ## Format code using rustfmt cargo fmt + cargo sort --workspace npx prettier --config ./.prettierrc --write '**/*.{ts,js,mts,mjs}' .PHONY: clippy @@ -229,8 +230,8 @@ testnet: genesis build-docker ## Start testnet as defined in QUAKE_MANIFEST file $(QUAKE) -f $(QUAKE_MANIFEST) start $(QUAKE_START_ARGS) .PHONY: testnet-test -testnet-test: ## Run tests against running testnet - $(QUAKE) test +testnet-test: ## Run tests against running testnet (optionally SPEC=group:name to scope) + $(QUAKE) test $(SPEC) .PHONY: testnet-down testnet-down: ## Stop testnet diff --git a/assets/localdev/genesis.config.ts b/assets/localdev/genesis.config.ts index fbf4315f..fc7d9933 100644 --- a/assets/localdev/genesis.config.ts +++ b/assets/localdev/genesis.config.ts @@ -47,7 +47,15 @@ const build = async (options: z.infer) => { network: 'localdev', chainId: 1337, }) - const { outputControllersConfig, outputGenesisConfig, validatorNames, hardforks, extraAccountBalance, blockGasLimit, ...accountOptions } = options + const { + outputControllersConfig, + outputGenesisConfig, + validatorNames, + hardforks, + extraAccountBalance, + blockGasLimit, + ...accountOptions + } = options const accountCreator = new LocalDevAccountCreator(accountOptions) // Default account for hardhat environment. @@ -94,7 +102,10 @@ const build = async (options: z.infer) => { .concat([one.address]) .concat(controllers.map((x) => x.address)) .concat(accountCreator.extraPrefundAccounts().map((x) => x.address)) - .map((address) => ({ address: address, balance: extraAccountBalance !== undefined ? parseEther(extraAccountBalance.toString()) : parseEther('1000000') })), + .map((address) => ({ + address: address, + balance: extraAccountBalance !== undefined ? parseEther(extraAccountBalance.toString()) : parseEther('1000000'), + })), NativeFiatToken: { proxy: { admin: proxyAdmin.address }, @@ -118,8 +129,8 @@ const build = async (options: z.infer) => { alpha: 20n, // 20% kRate: 200n, // 2% inverseElasticityMultiplier: 5000n, // 50% - minBaseFee: 1n, - maxBaseFee: parseGwei('1000'), + minBaseFee: parseGwei('20'), + maxBaseFee: parseGwei('20000'), blockGasLimit: blockGasLimit ?? 30_000_000n, }, consensusParams: { @@ -129,7 +140,7 @@ const build = async (options: z.infer) => { timeoutPrevoteDeltaMs: 500n, timeoutPrecommitMs: 1000n, timeoutPrecommitDeltaMs: 500n, - timeoutRebroadcastMs: 1000n, + timeoutRebroadcastMs: 5000n, targetBlockTimeMs: 500n, }, }, diff --git a/assets/localdev/genesis.json b/assets/localdev/genesis.json index 433c2100..eb92b83c 100644 --- a/assets/localdev/genesis.json +++ b/assets/localdev/genesis.json @@ -26,6 +26,7 @@ "zero5Block": 0, "zero6Block": 0, "zero7Time": 0, + "zero8Time": 0, "osakaTime": 0 }, "nonce": "0x0", @@ -85,10 +86,10 @@ "0x958f8fec699b51a1249f513eceda5429078000657f74abd1721bba363087af00": "0x00000000000000000000000023618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f", "0x0642d7922329a434cf4fd17a3c95eb692c24fd95f9f94d0b55420a5d895f4a00": "0x00000000000000000000000023618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f", "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385200": "0x0000000000000000000000000000138800000000000000c80000000000000014", - "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385201": "0x0000000000000000000000000000000000000000000000000000000000000001", - "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385202": "0x000000000000000000000000000000000000000000000000000000e8d4a51000", + "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385201": "0x00000000000000000000000000000000000000000000000000000004a817c800", + "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385202": "0x000000000000000000000000000000000000000000000000000012309ce54000", "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385203": "0x0000000000000000000000000000000000000000000000000000000001c9c380", - "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385205": "0x0000000000000000000000000000000001f403e801f403e801f403e801f40bb8" + "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385205": "0x0000000000000000000000000000000001f4138801f403e801f403e801f40bb8" } }, "0xfCc314DD5Ad756C6bBA725617438C0d25450a0dE": { diff --git a/crates/consensus-db/src/lib.rs b/crates/consensus-db/src/lib.rs index bb12a616..b7c1a5e4 100644 --- a/crates/consensus-db/src/lib.rs +++ b/crates/consensus-db/src/lib.rs @@ -27,10 +27,10 @@ pub mod versions; mod store; pub use store::{ - rollback_to_height, DbUpgrade, RollbackReport, Store, StoreError, CERTIFICATES_TABLE, - DECIDED_BLOCKS_TABLE, INVALID_PAYLOADS_TABLE, MISBEHAVIOR_EVIDENCE_TABLE, - PENDING_PROPOSAL_PARTS_TABLE, PROPOSAL_MONITOR_DATA_TABLE, ROLLBACK_BATCH_SIZE, - UNDECIDED_BLOCKS_TABLE, + rollback_to_height, DbUpgrade, RangeFailureReason, RangeQueryResult, RollbackReport, Store, + StoreError, CERTIFICATES_TABLE, DECIDED_BLOCKS_TABLE, INVALID_PAYLOADS_TABLE, + MISBEHAVIOR_EVIDENCE_TABLE, PENDING_PROPOSAL_PARTS_TABLE, PROPOSAL_MONITOR_DATA_TABLE, + ROLLBACK_BATCH_SIZE, UNDECIDED_BLOCKS_TABLE, }; mod metrics; diff --git a/crates/consensus-db/src/repositories/invalid_payloads.rs b/crates/consensus-db/src/repositories/invalid_payloads.rs index c1605b97..06d6ec76 100644 --- a/crates/consensus-db/src/repositories/invalid_payloads.rs +++ b/crates/consensus-db/src/repositories/invalid_payloads.rs @@ -29,6 +29,10 @@ pub trait InvalidPayloadsRepository { /// /// Creates the underlying collection for the payload's height if one does not /// already exist. + /// + /// This is best-effort: the record is diagnostic-only and consensus does not + /// depend on it reaching disk. Callers must not let a persistence failure abort + /// their primary path — log the error and continue with the authoritative outcome. async fn append(&self, invalid_payload: InvalidPayload) -> Result<(), Self::Error>; } diff --git a/crates/consensus-db/src/services/pruning.rs b/crates/consensus-db/src/services/pruning.rs index 936610ef..4e52e925 100644 --- a/crates/consensus-db/src/services/pruning.rs +++ b/crates/consensus-db/src/services/pruning.rs @@ -15,7 +15,7 @@ // limitations under the License. use arc_consensus_types::{Height, PruningConfig}; -use tracing::info; +use tracing::{error, info}; use crate::store::{Store, StoreError}; @@ -32,10 +32,16 @@ pub trait PruningService { /// in the DB to help with EL's amnesia upon recovery. async fn prune_decided_blocks(&self) -> Result, Self::Error>; - /// Prune historical certificates. + /// Prune historical data that shares the certificate retention window. + /// + /// Implementations are expected to prune the certificates table and any + /// additional height-keyed tables that use the same retain window (e.g. + /// diagnostic records). The returned heights reflect only what was pruned + /// from the certificates table; additional tables pruned alongside are not + /// surfaced in the return value. /// /// # Important - /// This will only prune certificates if pruning is enabled. + /// Only runs when pruning is enabled. /// /// # Arguments /// - `latest_height`: The latest committed height. Used to determine the effective retain height. @@ -54,28 +60,6 @@ pub trait PruningService { async fn clean_stale_consensus_data(&self, current_height: Height) -> Result<(), Self::Error>; } -impl PruningService for &T -where - T: PruningService + ?Sized, -{ - type Error = T::Error; - - async fn prune_decided_blocks(&self) -> Result, Self::Error> { - T::prune_decided_blocks(*self).await - } - - async fn prune_historical_certs( - &self, - latest_height: Height, - ) -> Result, Self::Error> { - T::prune_historical_certs(*self, latest_height).await - } - - async fn clean_stale_consensus_data(&self, current_height: Height) -> Result<(), Self::Error> { - T::clean_stale_consensus_data(*self, current_height).await - } -} - pub struct ProdPruningService<'a> { store: &'a Store, config: &'a PruningConfig, @@ -105,10 +89,184 @@ impl<'a> PruningService for ProdPruningService<'a> { let retain_height = self.config.effective_certificates_min_height(latest_height); info!(height = %latest_height, %retain_height, "Pruning historical data"); - self.store.prune_historical_certs(retain_height).await + + // Each table is pruned independently so a transient failure on one (e.g. a + // brief I/O hiccup) does not prevent the others from making progress. The + // certificate-table result is surfaced to the caller; diagnostic-table + // failures are logged here. + let pruned_certs = self.store.prune_historical_certs(retain_height).await; + + if let Err(e) = self.store.prune_proposal_monitor_data(retain_height).await { + error!(%retain_height, "Failed to prune proposal monitor data: {e}"); + } + if let Err(e) = self.store.prune_misbehavior_evidence(retain_height).await { + error!(%retain_height, "Failed to prune misbehavior evidence: {e}"); + } + if let Err(e) = self.store.prune_invalid_payloads(retain_height).await { + error!(%retain_height, "Failed to prune invalid payloads: {e}"); + } + + pruned_certs } async fn clean_stale_consensus_data(&self, current_height: Height) -> Result<(), StoreError> { self.store.clean_stale_consensus_data(current_height).await } } + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use alloy_rpc_types_engine::ExecutionPayloadV3; + use arbitrary::Unstructured; + use arc_consensus_types::block::ConsensusBlock; + use arc_consensus_types::evidence::StoredMisbehaviorEvidence; + use arc_consensus_types::proposal_monitor::ProposalMonitor; + use arc_consensus_types::{Address, ArcContext, Round, ValueId}; + use bytesize::ByteSize; + use malachitebft_app_channel::app::types::core::{CommitCertificate, Validity}; + use tempfile::tempdir; + + use super::*; + use crate::invalid_payloads::InvalidPayload; + use crate::metrics::DbMetrics; + use crate::store::DbUpgrade; + + async fn create_store() -> Store { + let dir = tempdir().unwrap(); + Store::open( + dir.path().join("db"), + DbMetrics::default(), + DbUpgrade::Skip, + ByteSize::mib(64), + ) + .await + .unwrap() + } + + fn arbitrary_payload() -> ExecutionPayloadV3 { + Unstructured::new(&[0xab; 1024]) + .arbitrary::() + .unwrap() + } + + async fn seed_all_tables_at_height(store: &Store, height: Height) { + let round = Round::new(0); + let payload = arbitrary_payload(); + let block_hash = payload.payload_inner.payload_inner.block_hash; + let value_id = ValueId::new(block_hash); + let cert = CommitCertificate::::new(height, round, value_id, vec![]); + let block = ConsensusBlock { + height, + round, + valid_round: round, + proposer: Address::new([0u8; 20]), + validity: Validity::Valid, + execution_payload: payload, + signature: None, + }; + store + .store_decided_block(cert, block.execution_payload, block.proposer) + .await + .unwrap(); + store + .store_proposal_monitor_data(ProposalMonitor::new( + height, + Address::new([0u8; 20]), + SystemTime::now(), + )) + .await + .unwrap(); + store + .store_misbehavior_evidence(StoredMisbehaviorEvidence::empty(height)) + .await + .unwrap(); + store + .append_invalid_payload(InvalidPayload::new_without_payload( + height, + round, + Address::new([0u8; 20]), + "test", + )) + .await + .unwrap(); + } + + #[tokio::test] + async fn prune_historical_certs_also_prunes_diagnostic_tables_when_enabled() { + let store = create_store().await; + for h in 1u64..=5 { + seed_all_tables_at_height(&store, Height::new(h)).await; + } + + let config = PruningConfig { + certificates_distance: 0, + certificates_before: Height::new(4), + }; + let service = ProdPruningService::new(&store, &config); + + let pruned = service + .prune_historical_certs(Height::new(5)) + .await + .unwrap(); + assert_eq!(pruned, vec![Height::new(1), Height::new(2), Height::new(3)]); + + // Re-running with the same retain_height must be a no-op across every table. + assert!(store + .prune_historical_certs(Height::new(4)) + .await + .unwrap() + .is_empty()); + assert!(store + .prune_proposal_monitor_data(Height::new(4)) + .await + .unwrap() + .is_empty()); + assert!(store + .prune_misbehavior_evidence(Height::new(4)) + .await + .unwrap() + .is_empty()); + assert!(store + .prune_invalid_payloads(Height::new(4)) + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn prune_historical_certs_is_noop_when_pruning_disabled() { + let store = create_store().await; + for h in 1u64..=3 { + seed_all_tables_at_height(&store, Height::new(h)).await; + } + + let config = PruningConfig::default(); + let service = ProdPruningService::new(&store, &config); + + let pruned = service + .prune_historical_certs(Height::new(3)) + .await + .unwrap(); + assert!(pruned.is_empty()); + + // All diagnostic tables must still have their rows — a follow-up prune at a + // high retain_height would drain them. + let drained_monitor = store + .prune_proposal_monitor_data(Height::new(999)) + .await + .unwrap(); + assert_eq!(drained_monitor.len(), 3); + let drained_evidence = store + .prune_misbehavior_evidence(Height::new(999)) + .await + .unwrap(); + assert_eq!(drained_evidence.len(), 3); + let drained_invalid = store + .prune_invalid_payloads(Height::new(999)) + .await + .unwrap(); + assert_eq!(drained_invalid.len(), 3); + } +} diff --git a/crates/consensus-db/src/store.rs b/crates/consensus-db/src/store.rs index cafb67a0..72c499a6 100644 --- a/crates/consensus-db/src/store.rs +++ b/crates/consensus-db/src/store.rs @@ -33,7 +33,7 @@ use tracing::{debug, error, info, warn}; use arc_consensus_types::evidence::StoredMisbehaviorEvidence; use arc_consensus_types::{ Address, ArcContext, BlockHash, CommitCertificateType, Height, ProposalParts, - StoredCommitCertificate, B256, + StoredCommitCertificate, }; use malachitebft_app_channel::app::types::core::{CommitCertificate, Round}; use malachitebft_core_types::Height as _; @@ -99,6 +99,33 @@ impl StoreError { } } +/// Why one or more heights in a range read from the RPC APIs could not be served. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RangeFailureReason { + /// The height is above the latest decided height. + AboveCurrentHead, + /// The height is below the earliest retained certificate. + Pruned, + /// The height is within the available window but was never recorded. + NotRecorded, + /// The record is missing or undecodable within the available window. + Internal, +} + +/// Result of a multi-height read resolved against one database snapshot. +#[derive(Debug, PartialEq, Eq)] +pub enum RangeQueryResult { + /// Every height produced an item, in ascending height order. + Complete(Vec), + /// One or more heights failed. + Unavailable { + /// Failure class of the lowest failed height. + reason: RangeFailureReason, + /// All failed heights of that class, in ascending order. + failed_heights: Vec, + }, +} + /// Per-table deletion counts from a rollback operation. #[derive(Debug, Default)] pub struct RollbackReport { @@ -794,6 +821,154 @@ impl Db { Ok(()) } + /// Read heights `from..=from+count-1` from `table`, classifying each + /// height against the available window. + /// + /// The window `[earliest, head]` comes from the certificates table in + /// the same read transaction, so the window and the data rows are one + /// consistent snapshot. + /// + /// `on_missing` decides what an absent row inside the window means for + /// the queried table: a synthesized item or a failure. + fn read_height_range( + &self, + table: redb::TableDefinition>, + from: Height, + count: u64, + decode: impl Fn(&[u8]) -> Result, + on_missing: impl Fn(Height) -> Result, + ) -> Result>, StoreError> { + let start = Instant::now(); + let mut read_bytes = 0usize; + let mut key_read_bytes = 0usize; + + let to = count + .checked_sub(1) + .and_then(|offset| from.as_u64().checked_add(offset)) + .ok_or_else(|| { + StoreError::other(format!("invalid height range: from={from}, count={count}")) + })?; + + let tx = self.db.begin_read()?; + let (earliest, head) = { + // Opening CERTIFICATES_TABLE to know the height window because other tables are sparse + let certs = tx.open_table(CERTIFICATES_TABLE)?; + match (certs.first()?, certs.last()?) { + (Some((first, _)), Some((last, _))) => (first.value(), last.value()), + _ => return Ok(None), + } + }; + + let table = tx.open_table(table)?; + + let mut items = Vec::new(); + let mut failures: Vec<(Height, RangeFailureReason)> = Vec::new(); + + for h in from.as_u64()..=to { + let height = Height::new(h); + + if height < earliest { + failures.push((height, RangeFailureReason::Pruned)); + continue; + } + if height > head { + failures.push((height, RangeFailureReason::AboveCurrentHead)); + continue; + } + key_read_bytes = key_read_bytes.saturating_add(size_of::()); + match table.get(&height)? { + Some(value) => { + let bytes = value.value(); + read_bytes = read_bytes.saturating_add(bytes.len()); + match decode(&bytes) { + Ok(item) => items.push(item), + Err(_) => failures.push((height, RangeFailureReason::Internal)), + } + } + None => match on_missing(height) { + Ok(item) => items.push(item), + Err(reason) => failures.push((height, reason)), + }, + } + } + + self.update_read_metrics(read_bytes, key_read_bytes, start.elapsed()); + + let result = match failures.first() { + None => RangeQueryResult::Complete(items), + Some(&(_, reason)) => RangeQueryResult::Unavailable { + reason, + failed_heights: failures + .iter() + .filter(|(_, r)| *r == reason) + .map(|(height, _)| *height) + .collect(), + }, + }; + + Ok(Some(result)) + } + + /// Get commit certificates for heights `from..=from+count-1`. + /// Returns `Ok(None)` when the store has no certificates at all. + fn get_certificate_range( + &self, + from: Height, + count: u64, + ) -> Result>, StoreError> { + self.read_height_range(CERTIFICATES_TABLE, from, count, decode_certificate, |_| { + Err(RangeFailureReason::Internal) + }) + } + + /// Get misbehavior evidence for heights `from..=from+count-1`. + /// Heights inside the window without a record yield empty evidence. + fn get_misbehavior_evidence_range( + &self, + from: Height, + count: u64, + ) -> Result>, StoreError> { + self.read_height_range( + MISBEHAVIOR_EVIDENCE_TABLE, + from, + count, + decode_misbehavior_evidence, + |height| Ok(StoredMisbehaviorEvidence::empty(height)), + ) + } + + /// Get proposal monitor data for heights `from..=from+count-1`. + /// Heights inside the window without a record are permanent holes. + fn get_proposal_monitor_data_range( + &self, + from: Height, + count: u64, + ) -> Result>, StoreError> { + self.read_height_range( + PROPOSAL_MONITOR_DATA_TABLE, + from, + count, + decode_proposal_monitor_data, + |_| Err(RangeFailureReason::NotRecorded), + ) + } + + /// Get invalid payloads for heights `from..=from+count-1`. + /// Heights inside the window without a record yield empty payloads. + fn get_invalid_payloads_range( + &self, + from: Height, + count: u64, + ) -> Result>, StoreError> { + self.read_height_range( + INVALID_PAYLOADS_TABLE, + from, + count, + decode_invalid_payloads, + |height| Ok(StoredInvalidPayloads::empty(height)), + ) + } + /// Get the undecided block for the given height, round, and block hash. #[tracing::instrument(skip(self))] pub fn get_undecided_block( @@ -929,7 +1104,7 @@ impl Db { ) -> Result<(), StoreError> { let start = Instant::now(); - let key = (block.height, block.round, block.block_hash()); + let key = (block.height, block.round, block.self_reported_block_hash()); let value = encode_block(&block); { @@ -949,7 +1124,7 @@ impl Db { ) -> Result<(), StoreError> { let start = Instant::now(); - let key = (parts.height(), parts.round(), B256::new(parts.hash())); + let key = parts.pending_key(); { let mut table = tx.open_table(PENDING_PROPOSAL_PARTS_TABLE)?; @@ -1084,7 +1259,7 @@ impl Db { let mut inserted = false; - let key = (parts.height(), parts.round(), B256::new(parts.hash())); + let key = parts.pending_key(); let value = encode_proposal_parts(&parts)?; // Insert the proposal if there is room in the table @@ -1209,9 +1384,15 @@ impl Db { Ok(()) } - /// Prune up to PRUNE_BATCH_LIMIT historical certificates below retain_height. - /// This should only run when pruning is enabled. - fn prune_historical_certs(&self, retain_height: Height) -> Result, StoreError> { + /// Prune up to `PRUNE_BATCH_LIMIT` entries below `retain_height` from a height-keyed table. + /// Reads the batch of stale keys in a read transaction, then deletes them in a short + /// write transaction. + fn prune_height_table_batch( + &self, + retain_height: Height, + table: redb::TableDefinition>, + label: &str, + ) -> Result, StoreError> { let start = Instant::now(); let curr_height = self.max_height()?.unwrap_or_default(); @@ -1219,26 +1400,25 @@ impl Db { let keys = { let tx_read = self.db.begin_read()?; - let certificates = tx_read.open_table(CERTIFICATES_TABLE)?; - self.height_range(&certificates, ..retain_height, Self::PRUNE_BATCH_LIMIT)? + let t = tx_read.open_table(table)?; + self.height_range(&t, ..retain_height, Self::PRUNE_BATCH_LIMIT)? }; if keys.is_empty() { if log_info { - info!(%retain_height, %curr_height, "No historical certificates to prune in this batch"); + info!(%retain_height, %curr_height, table = label, "No entries to prune in this batch"); } else { - debug!(%retain_height, %curr_height, "No historical certificates to prune in this batch"); + debug!(%retain_height, %curr_height, table = label, "No entries to prune in this batch"); } self.update_delete_metrics(start.elapsed()); return Ok(keys); } - // Remove collected keys within a short write transaction let tx_write = self.db.begin_write()?; { - let mut certificates = tx_write.open_table(CERTIFICATES_TABLE)?; + let mut t = tx_write.open_table(table)?; for h in &keys { - let _ = certificates.remove(h)?; + let _ = t.remove(h)?; } } tx_write.commit()?; @@ -1252,7 +1432,8 @@ impl Db { current_height = %curr_height, %first_pruned, %last_pruned, - "Pruned historical certificates batch" + table = label, + "Pruned entries batch" ); } else { debug!( @@ -1261,7 +1442,8 @@ impl Db { current_height = %curr_height, %first_pruned, %last_pruned, - "Pruned historical certificates batch" + table = label, + "Pruned entries batch" ); } @@ -1270,67 +1452,47 @@ impl Db { Ok(keys) } - /// Prune up to PRUNE_BATCH_LIMIT blocks below (current_height - `EL_AMNESIA_HEIGHT_COUNT`). + /// Prune up to PRUNE_BATCH_LIMIT historical certificates below retain_height. + /// This should only run when pruning is enabled. + fn prune_historical_certs(&self, retain_height: Height) -> Result, StoreError> { + self.prune_height_table_batch(retain_height, CERTIFICATES_TABLE, "certificates") + } + + /// Prune up to PRUNE_BATCH_LIMIT blocks below (current_height - `RETH_AMNESIA_HEIGHT_COUNT`). /// This should run regardless of whether pruning is enabled. fn prune_blocks(&self) -> Result, StoreError> { - let start = Instant::now(); - let curr_height = self.max_height()?.unwrap_or_default(); - let log_info = curr_height.as_u64() % Self::PRUNING_LOG_INFO_HEIGHTS == 0; let retain_height = curr_height.saturating_sub(Self::RETH_AMNESIA_HEIGHT_COUNT); + self.prune_height_table_batch(retain_height, DECIDED_BLOCKS_TABLE, "decided_blocks") + } - let keys = { - let tx_read = self.db.begin_read()?; - let decided = tx_read.open_table(DECIDED_BLOCKS_TABLE)?; - self.height_range(&decided, ..retain_height, Self::PRUNE_BATCH_LIMIT)? - }; - - if keys.is_empty() { - if log_info { - info!(%retain_height, %curr_height, "No decided blocks to prune in this batch"); - } else { - debug!(%retain_height, %curr_height, "No decided blocks to prune in this batch"); - } - self.update_delete_metrics(start.elapsed()); - return Ok(keys); - } - - // Remove collected keys within a short write transaction - let tx_write = self.db.begin_write()?; - { - let mut decided = tx_write.open_table(DECIDED_BLOCKS_TABLE)?; - for h in &keys { - let _ = decided.remove(h)?; - } - } - tx_write.commit()?; - - let first_pruned = keys.first().expect("'keys' should not be empty").as_u64(); - let last_pruned = keys.last().expect("'keys' should not be empty").as_u64(); - - if log_info { - info!( - pruned_count = keys.len(), - %retain_height, - current_height = %curr_height, - %first_pruned, - %last_pruned, - "Pruned decided blocks batch" - ); - } else { - debug!( - pruned_count = keys.len(), - %retain_height, - current_height = %curr_height, - %first_pruned, - %last_pruned, - "Pruned decided blocks batch" - ); - } + /// Prune up to PRUNE_BATCH_LIMIT proposal-monitor records below retain_height. + /// This should only run when pruning is enabled. + fn prune_proposal_monitor_data( + &self, + retain_height: Height, + ) -> Result, StoreError> { + self.prune_height_table_batch( + retain_height, + PROPOSAL_MONITOR_DATA_TABLE, + "proposal_monitor_data", + ) + } - self.update_delete_metrics(start.elapsed()); + /// Prune up to PRUNE_BATCH_LIMIT misbehavior-evidence records below retain_height. + /// This should only run when pruning is enabled. + fn prune_misbehavior_evidence(&self, retain_height: Height) -> Result, StoreError> { + self.prune_height_table_batch( + retain_height, + MISBEHAVIOR_EVIDENCE_TABLE, + "misbehavior_evidence", + ) + } - Ok(keys) + /// Prune up to PRUNE_BATCH_LIMIT invalid-payload records below retain_height. + /// This should only run when pruning is enabled. + fn prune_invalid_payloads(&self, retain_height: Height) -> Result, StoreError> { + self.prune_height_table_batch(retain_height, INVALID_PAYLOADS_TABLE, "invalid_payloads") } fn limit_height(&self, min: bool) -> Result, StoreError> { @@ -1577,6 +1739,61 @@ impl Store { tokio::task::spawn_blocking(move || db.get_invalid_payloads(height)).await? } + /// Get commit certificates for heights `from..=from+count-1`, resolved + /// against one database snapshot. + /// + /// Returns `Ok(None)` when the store has no certificates at all. + pub async fn get_certificate_range( + &self, + from: Height, + count: u64, + ) -> Result>, StoreError> { + let db = Arc::clone(&self.db); + tokio::task::spawn_blocking(move || db.get_certificate_range(from, count)).await? + } + + /// Get misbehavior evidence for heights `from..=from+count-1`, resolved + /// against one database snapshot. Heights inside the available window + /// without a record yield empty evidence. + /// + /// Returns `Ok(None)` when the store has no certificates at all. + pub async fn get_misbehavior_evidence_range( + &self, + from: Height, + count: u64, + ) -> Result>, StoreError> { + let db = Arc::clone(&self.db); + tokio::task::spawn_blocking(move || db.get_misbehavior_evidence_range(from, count)).await? + } + + /// Get proposal monitor data for heights `from..=from+count-1`, resolved + /// against one database snapshot. Heights inside the available window + /// without a record fail as permanently unrecorded. + /// + /// Returns `Ok(None)` when the store has no certificates at all. + pub async fn get_proposal_monitor_data_range( + &self, + from: Height, + count: u64, + ) -> Result>, StoreError> { + let db = Arc::clone(&self.db); + tokio::task::spawn_blocking(move || db.get_proposal_monitor_data_range(from, count)).await? + } + + /// Get invalid payloads for heights `from..=from+count-1`, resolved + /// against one database snapshot. Heights inside the available window + /// without a record yield empty payloads. + /// + /// Returns `Ok(None)` when the store has no certificates at all. + pub async fn get_invalid_payloads_range( + &self, + from: Height, + count: u64, + ) -> Result>, StoreError> { + let db = Arc::clone(&self.db); + tokio::task::spawn_blocking(move || db.get_invalid_payloads_range(from, count)).await? + } + /// Appends an invalid payload to the stored collection for its height, creating /// the collection if none exists yet. /// @@ -1762,6 +1979,39 @@ impl Store { tokio::task::spawn_blocking(move || db.prune_blocks()).await? } + /// Prune historical proposal-monitor records. + /// Should only be called when pruning is enabled. + /// - retain_height: The minimum height to retain. + pub async fn prune_proposal_monitor_data( + &self, + retain_height: Height, + ) -> Result, StoreError> { + let db = Arc::clone(&self.db); + tokio::task::spawn_blocking(move || db.prune_proposal_monitor_data(retain_height)).await? + } + + /// Prune historical misbehavior-evidence records. + /// Should only be called when pruning is enabled. + /// - retain_height: The minimum height to retain. + pub async fn prune_misbehavior_evidence( + &self, + retain_height: Height, + ) -> Result, StoreError> { + let db = Arc::clone(&self.db); + tokio::task::spawn_blocking(move || db.prune_misbehavior_evidence(retain_height)).await? + } + + /// Prune historical invalid-payload records. + /// Should only be called when pruning is enabled. + /// - retain_height: The minimum height to retain. + pub async fn prune_invalid_payloads( + &self, + retain_height: Height, + ) -> Result, StoreError> { + let db = Arc::clone(&self.db); + tokio::task::spawn_blocking(move || db.prune_invalid_payloads(retain_height)).await? + } + /// Create a savepoint in the database to ensure the allocator state table is up to date. /// Doing this before shutting down the database can help avoid repair on next startup. pub fn savepoint(&self) { @@ -1773,12 +2023,14 @@ impl Store { #[cfg(test)] mod tests { use super::*; + use std::time::SystemTime; + use alloy_rpc_types_engine::ExecutionPayloadV3; use arbitrary::Unstructured; use arc_consensus_types::signing::Signature; use arc_consensus_types::{ Address, CommitSignature, ProposalData, ProposalFin, ProposalInit, ProposalPart, ValueId, - Vote, + Vote, B256, }; use bytes::Bytes; use malachitebft_app_channel::app::types::core::Validity; @@ -2050,7 +2302,7 @@ mod tests { store.store_undecided_block(block.clone()).await.unwrap(); let retrieved = store - .get_undecided_block(height, round, block.block_hash()) + .get_undecided_block(height, round, block.self_reported_block_hash()) .await .unwrap(); @@ -2097,6 +2349,194 @@ mod tests { assert_eq!(counts, vec![(height_a, 3), (height_b, 2)]); } + #[tokio::test] + async fn test_pending_parts_different_proposer_get_distinct_keys() { + let store = create_store().await; + let height = Height::new(5); + let round = Round::new(0); + + let parts_a = create_test_proposal_parts(height, round, Address::new([1u8; 20])).await; + let parts_b = create_test_proposal_parts(height, round, Address::new([2u8; 20])).await; + + let inserted_a = store + .store_pending_proposal_parts(parts_a, 100, Height::new(1)) + .await + .unwrap(); + let inserted_b = store + .store_pending_proposal_parts(parts_b, 100, Height::new(1)) + .await + .unwrap(); + + assert!(inserted_a); + assert!(inserted_b); + + let pending = store + .get_pending_proposal_parts(height, round) + .await + .unwrap(); + + let mut proposers = pending + .iter() + .map(|p| p.proposer().into_inner()) + .collect::>(); + proposers.sort_unstable(); + assert_eq!(proposers, vec![[1u8; 20], [2u8; 20]]); + } + + #[tokio::test] + async fn test_pending_parts_different_pol_round_get_distinct_keys() { + let store = create_store().await; + let height = Height::new(5); + let round = Round::new(2); + let proposer = Address::new([1u8; 20]); + let signature = Signature::from_bytes([0u8; 64]); + + let parts_a = ProposalParts::new(vec![ + ProposalPart::Init(ProposalInit::new(height, round, Round::Nil, proposer)), + ProposalPart::Data(ProposalData::new(Bytes::from_static(b"test data"))), + ProposalPart::Fin(ProposalFin::new(signature)), + ]) + .unwrap(); + + let parts_b = ProposalParts::new(vec![ + ProposalPart::Init(ProposalInit::new(height, round, Round::new(0), proposer)), + ProposalPart::Data(ProposalData::new(Bytes::from_static(b"test data"))), + ProposalPart::Fin(ProposalFin::new(signature)), + ]) + .unwrap(); + + let inserted_a = store + .store_pending_proposal_parts(parts_a, 100, Height::new(1)) + .await + .unwrap(); + let inserted_b = store + .store_pending_proposal_parts(parts_b, 100, Height::new(1)) + .await + .unwrap(); + + assert!(inserted_a); + assert!(inserted_b); + + let pending = store + .get_pending_proposal_parts(height, round) + .await + .unwrap(); + + let mut pol_rounds = pending + .iter() + .map(|p| p.init().pol_round.as_i64()) + .collect::>(); + pol_rounds.sort_unstable(); + assert_eq!(pol_rounds, vec![Round::Nil.as_i64(), 0]); + } + + #[tokio::test] + async fn test_pending_parts_exact_duplicate_is_idempotent() { + let store = create_store().await; + let height = Height::new(5); + let round = Round::new(0); + let proposer = Address::new([1u8; 20]); + + let parts = create_test_proposal_parts(height, round, proposer).await; + + let inserted_first = store + .store_pending_proposal_parts(parts.clone(), 100, Height::new(1)) + .await + .unwrap(); + let inserted_second = store + .store_pending_proposal_parts(parts, 100, Height::new(1)) + .await + .unwrap(); + + assert!(inserted_first); + assert!(inserted_second); + + let pending = store + .get_pending_proposal_parts(height, round) + .await + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].proposer(), proposer); + } + + #[tokio::test] + async fn test_pending_parts_different_signature_get_distinct_keys() { + let store = create_store().await; + let height = Height::new(5); + let round = Round::new(0); + let proposer = Address::new([1u8; 20]); + + let build = |signature| { + ProposalParts::new(vec![ + ProposalPart::Init(ProposalInit::new(height, round, Round::Nil, proposer)), + ProposalPart::Data(ProposalData::new(Bytes::from_static(b"test data"))), + ProposalPart::Fin(ProposalFin::new(signature)), + ]) + .unwrap() + }; + + let parts_a = build(Signature::from_bytes([1u8; 64])); + let parts_b = build(Signature::from_bytes([2u8; 64])); + + assert!(store + .store_pending_proposal_parts(parts_a, 100, Height::new(1)) + .await + .unwrap()); + assert!(store + .store_pending_proposal_parts(parts_b, 100, Height::new(1)) + .await + .unwrap()); + + let pending = store + .get_pending_proposal_parts(height, round) + .await + .unwrap(); + + let mut signatures = pending + .iter() + .map(|p| p.fin().signature.to_bytes()) + .collect::>(); + signatures.sort_unstable(); + assert_eq!(signatures, vec![[1u8; 64], [2u8; 64]]); + } + + /// At capacity, a later variant is declined rather than displacing the entry + /// already held for the same height and round. + #[tokio::test] + async fn test_pending_parts_variant_cannot_displace_entry_at_capacity() { + let store = create_store().await; + let height = Height::new(1); + let round = Round::new(0); + let first_proposer = Address::new([1u8; 20]); + + let first = create_test_proposal_parts(height, round, first_proposer).await; + let variant = create_test_proposal_parts(height, round, Address::new([2u8; 20])).await; + + // Capacity of one: the first entry fills the table. + assert!(store + .store_pending_proposal_parts(first, 1, height) + .await + .unwrap()); + assert!( + !store + .store_pending_proposal_parts(variant, 1, height) + .await + .unwrap(), + "table is full, the variant must be declined" + ); + + let pending = store + .get_pending_proposal_parts(height, round) + .await + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!( + pending[0].proposer(), + first_proposer, + "the entry already held must survive" + ); + } + #[tokio::test] async fn test_prune_historical_certs() { let store = create_store().await; @@ -2468,6 +2908,160 @@ mod tests { ); } + async fn store_proposal_monitor_at_height(store: &Store, height: Height) { + let monitor = ProposalMonitor::new(height, Address::new([0u8; 20]), SystemTime::now()); + store.store_proposal_monitor_data(monitor).await.unwrap(); + } + + async fn store_empty_misbehavior_evidence_at_height(store: &Store, height: Height) { + store + .store_misbehavior_evidence(StoredMisbehaviorEvidence::empty(height)) + .await + .unwrap(); + } + + async fn append_invalid_payload_at_height(store: &Store, height: Height) { + let payload = InvalidPayload::new_without_payload( + height, + Round::new(0), + Address::new([0u8; 20]), + "test", + ); + store.append_invalid_payload(payload).await.unwrap(); + } + + #[tokio::test] + async fn test_prune_proposal_monitor_data() { + let store = create_store().await; + + let heights = [1u64, 2, 3, 4]; + for h in heights.iter() { + store_block_at_height(&store, Height::new(*h)).await; + store_proposal_monitor_at_height(&store, Height::new(*h)).await; + } + + let pruned = store + .prune_proposal_monitor_data(Height::new(3)) + .await + .unwrap(); + assert_eq!(pruned, vec![Height::new(1), Height::new(2)]); + + for h in heights.iter() { + let height = Height::new(*h); + let exists = store + .get_proposal_monitor_data(Some(height)) + .await + .unwrap() + .is_some(); + if *h < 3 { + assert!(!exists, "Proposal monitor at height {h} should be pruned"); + } else { + assert!(exists, "Proposal monitor at height {h} should be retained"); + } + } + } + + #[tokio::test] + async fn test_prune_misbehavior_evidence() { + let store = create_store().await; + + let heights = [1u64, 2, 3, 4]; + for h in heights.iter() { + store_block_at_height(&store, Height::new(*h)).await; + store_empty_misbehavior_evidence_at_height(&store, Height::new(*h)).await; + } + + let pruned = store + .prune_misbehavior_evidence(Height::new(3)) + .await + .unwrap(); + assert_eq!(pruned, vec![Height::new(1), Height::new(2)]); + + // Re-running with the same retain_height must be a no-op once rows are gone. + let pruned_again = store + .prune_misbehavior_evidence(Height::new(3)) + .await + .unwrap(); + assert!(pruned_again.is_empty()); + + // Raising retain_height must still find the remaining rows at heights 3 and 4. + let pruned_more = store + .prune_misbehavior_evidence(Height::new(5)) + .await + .unwrap(); + assert_eq!(pruned_more, vec![Height::new(3), Height::new(4)]); + } + + #[tokio::test] + async fn test_prune_invalid_payloads() { + let store = create_store().await; + + let heights = [1u64, 2, 3, 4]; + for h in heights.iter() { + store_block_at_height(&store, Height::new(*h)).await; + append_invalid_payload_at_height(&store, Height::new(*h)).await; + } + + let pruned = store.prune_invalid_payloads(Height::new(3)).await.unwrap(); + assert_eq!(pruned, vec![Height::new(1), Height::new(2)]); + + let pruned_again = store.prune_invalid_payloads(Height::new(3)).await.unwrap(); + assert!(pruned_again.is_empty()); + + let pruned_more = store.prune_invalid_payloads(Height::new(5)).await.unwrap(); + assert_eq!(pruned_more, vec![Height::new(3), Height::new(4)]); + } + + #[tokio::test] + async fn test_prune_proposal_monitor_data_batch_cap() { + let store = create_store().await; + + let limit = Db::PRUNE_BATCH_LIMIT as u64; + let retain_height = limit + 50; + let curr_height = retain_height + 2; + + for h in 1..=curr_height { + let height = Height::new(h); + store_block_at_height(&store, height).await; + store_proposal_monitor_at_height(&store, height).await; + } + + let pruned1 = store + .prune_proposal_monitor_data(Height::new(retain_height)) + .await + .unwrap(); + assert_eq!(pruned1.len() as u64, limit); + let expected1 = (1..=limit).map(Height::new).collect::>(); + assert_eq!(pruned1, expected1); + + let pruned2 = store + .prune_proposal_monitor_data(Height::new(retain_height)) + .await + .unwrap(); + assert_eq!(pruned2.len() as u64, retain_height - 1 - limit); + let expected2 = ((limit + 1)..retain_height) + .map(Height::new) + .collect::>(); + assert_eq!(pruned2, expected2); + + for h in 1..retain_height { + let exists = store + .get_proposal_monitor_data(Some(Height::new(h))) + .await + .unwrap() + .is_some(); + assert!(!exists, "Proposal monitor at height {h} should be pruned"); + } + for h in retain_height..=curr_height { + let exists = store + .get_proposal_monitor_data(Some(Height::new(h))) + .await + .unwrap() + .is_some(); + assert!(exists, "Proposal monitor at height {h} should be retained"); + } + } + async fn store_block_at_height(store: &Store, height: Height) { let round = Round::new(0); let payload = arbitrary_payload(); @@ -2913,4 +3507,300 @@ mod tests { ) .await; } + + #[tokio::test] + async fn test_get_certificate_range_complete() { + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=10).collect::>()).await; + + let result = store + .get_certificate_range(Height::new(3), 5) + .await + .unwrap(); + + let Some(RangeQueryResult::Complete(certs)) = result else { + panic!("expected complete range"); + }; + let heights: Vec = certs + .iter() + .map(|c| c.certificate.height.as_u64()) + .collect(); + assert_eq!(heights, vec![3, 4, 5, 6, 7]); + } + + #[tokio::test] + async fn test_get_certificate_range_above_head() { + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=10).collect::>()).await; + + let result = store + .get_certificate_range(Height::new(8), 5) + .await + .unwrap(); + + let Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + }) = result + else { + panic!("expected unavailable range"); + }; + assert_eq!(reason, RangeFailureReason::AboveCurrentHead); + assert_eq!(failed_heights, vec![Height::new(11), Height::new(12)]); + } + + #[tokio::test] + async fn test_get_certificate_range_below_head() { + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=10).collect::>()).await; + store.prune_historical_certs(Height::new(5)).await.unwrap(); + + let result = store + .get_certificate_range(Height::new(3), 6) + .await + .unwrap(); + + let Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + }) = result + else { + panic!("expected unavailable range"); + }; + assert_eq!(reason, RangeFailureReason::Pruned); + assert_eq!(failed_heights, vec![Height::new(3), Height::new(4)]); + } + + #[tokio::test] + async fn test_get_certificate_range_mixed_reports_first_class_only() { + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=10).collect::>()).await; + store.prune_historical_certs(Height::new(5)).await.unwrap(); + + // Height 4 is pruned and heights 11-12 are above head; only the + // first failure class encountered (pruned) is reported. + let result = store + .get_certificate_range(Height::new(4), 9) + .await + .unwrap(); + + let Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + }) = result + else { + panic!("expected unavailable range"); + }; + assert_eq!(reason, RangeFailureReason::Pruned); + assert_eq!(failed_heights, vec![Height::new(4)]); + } + + #[tokio::test] + async fn test_get_certificate_range_empty_store() { + let store = create_store().await; + + let result = store + .get_certificate_range(Height::new(1), 3) + .await + .unwrap(); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_get_certificate_range_count_one_matches_single_getter() { + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=5).collect::>()).await; + + let single = store + .get_certificate(Some(Height::new(3))) + .await + .unwrap() + .unwrap(); + let result = store + .get_certificate_range(Height::new(3), 1) + .await + .unwrap(); + + let Some(RangeQueryResult::Complete(certs)) = result else { + panic!("expected complete range"); + }; + assert_eq!(certs, vec![single]); + } + + #[tokio::test] + async fn test_get_certificate_range_count_zero_is_error() { + let store = create_store().await; + store_blocks_at_heights(&store, &[1]).await; + + let result = store.get_certificate_range(Height::new(1), 0).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_certificate_range_missing_row_is_internal() { + let store = create_store().await; + // Heights 1-3 and 5 are stored; height 4 is a hole inside [1, 5]. + // A certificate must exist for every decided height, so the hole + // is an invariant violation reported as Internal, not a synthesized + // empty record. + store_blocks_at_heights(&store, &[1, 2, 3, 5]).await; + + let result = store + .get_certificate_range(Height::new(1), 5) + .await + .unwrap(); + + let Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + }) = result + else { + panic!("expected unavailable range"); + }; + assert_eq!(reason, RangeFailureReason::Internal); + assert_eq!(failed_heights, vec![Height::new(4)]); + } + + #[tokio::test] + async fn test_get_certificate_range_undecodable_row_is_internal() { + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=5).collect::>()).await; + + // Overwrite height 3's row with bytes whose leading version byte + // (0xff) is not a valid CommitCertificateVersion, simulating on-disk + // corruption. The key stays valid, so the window remains [1, 5] and + // the present-but-undecodable row classifies as Internal. + { + let tx = store.db.db.begin_write().unwrap(); + { + let mut table = tx.open_table(CERTIFICATES_TABLE).unwrap(); + table.insert(Height::new(3), vec![0xffu8; 4]).unwrap(); + } + tx.commit().unwrap(); + } + + let result = store + .get_certificate_range(Height::new(1), 5) + .await + .unwrap(); + + let Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + }) = result + else { + panic!("expected unavailable range"); + }; + assert_eq!(reason, RangeFailureReason::Internal); + assert_eq!(failed_heights, vec![Height::new(3)]); + } + + #[tokio::test] + async fn test_get_misbehavior_evidence_range_synthesizes_empty() { + use arc_consensus_types::evidence::ValidatorEvidence; + + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=5).collect::>()).await; + + // A real (non-empty) record only at height 3. + let evidence = StoredMisbehaviorEvidence { + height: Height::new(3), + validators: vec![ValidatorEvidence { + address: Address::new([7u8; 20]), + double_votes: vec![], + double_proposals: vec![], + }], + }; + store.store_misbehavior_evidence(evidence).await.unwrap(); + + let result = store + .get_misbehavior_evidence_range(Height::new(1), 5) + .await + .unwrap(); + + let Some(RangeQueryResult::Complete(items)) = result else { + panic!("expected complete range"); + }; + let heights: Vec = items.iter().map(|e| e.height.as_u64()).collect(); + assert_eq!(heights, vec![1, 2, 3, 4, 5]); + assert!(!items[2].is_empty(), "stored record should be returned"); + assert!( + items.iter().all(|e| e.height.as_u64() == 3 || e.is_empty()), + "recordless heights should synthesize empty evidence" + ); + } + + #[tokio::test] + async fn test_get_invalid_payloads_range_synthesizes_empty() { + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=5).collect::>()).await; + append_invalid_payload_at_height(&store, Height::new(2)).await; + + let result = store + .get_invalid_payloads_range(Height::new(1), 5) + .await + .unwrap(); + + let Some(RangeQueryResult::Complete(items)) = result else { + panic!("expected complete range"); + }; + let heights: Vec = items.iter().map(|p| p.height.as_u64()).collect(); + assert_eq!(heights, vec![1, 2, 3, 4, 5]); + assert_eq!(items[1].payloads.len(), 1); + assert!( + items + .iter() + .all(|p| p.height.as_u64() == 2 || p.payloads.is_empty()), + "recordless heights should synthesize empty payloads" + ); + } + + #[tokio::test] + async fn test_get_proposal_monitor_range_with_gap() { + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=5).collect::>()).await; + for h in [1u64, 2, 4, 5] { + store_proposal_monitor_at_height(&store, Height::new(h)).await; + } + + let result = store + .get_proposal_monitor_data_range(Height::new(1), 5) + .await + .unwrap(); + + let Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + }) = result + else { + panic!("expected unavailable range"); + }; + assert_eq!(reason, RangeFailureReason::NotRecorded); + assert_eq!(failed_heights, vec![Height::new(3)]); + } + + #[tokio::test] + async fn test_get_proposal_monitor_range_complete() { + let store = create_store().await; + store_blocks_at_heights(&store, &(1..=5).collect::>()).await; + for h in 1u64..=5 { + store_proposal_monitor_at_height(&store, Height::new(h)).await; + } + + let result = store + .get_proposal_monitor_data_range(Height::new(1), 5) + .await + .unwrap(); + + // Proposal-monitor is the only sparse table whose on_missing + // returns Err, so a gap-free range is the only path that reaches + // Complete for it. + let Some(RangeQueryResult::Complete(items)) = result else { + panic!("expected complete range"); + }; + let heights: Vec = items.iter().map(|m| m.height.as_u64()).collect(); + assert_eq!(heights, vec![1, 2, 3, 4, 5]); + } } diff --git a/crates/engine-bench/Cargo.toml b/crates/engine-bench/Cargo.toml index 5d8b06df..16d96cb9 100644 --- a/crates/engine-bench/Cargo.toml +++ b/crates/engine-bench/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" alloy-genesis = { workspace = true } alloy-primitives = { workspace = true } alloy-rpc-types-engine = { workspace = true, features = ["jwt", "serde"] } +alloy-rpc-types-eth = { workspace = true } arc-eth-engine = { workspace = true } arc-execution-config = { workspace = true } arc-version = { workspace = true } @@ -23,7 +24,7 @@ clap = { workspace = true, features = ["derive", "env"] } color-eyre = { workspace = true } csv = { workspace = true } eyre = { workspace = true } -reqwest = { workspace = true, features = ["json", "rustls-tls", "native-tls-vendored"] } +reqwest = { workspace = true, features = ["json", "rustls", "native-tls-vendored"] } reth-cli = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/engine-bench/README.md b/crates/engine-bench/README.md index e6aa6f04..3fb66159 100644 --- a/crates/engine-bench/README.md +++ b/crates/engine-bench/README.md @@ -3,10 +3,16 @@ Inspired by [`reth-bench`](https://github.com/paradigmxyz/reth/tree/main/bin/reth-bench) from the upstream Reth project. -`arc-engine-bench` replays historical blocks into an Arc execution node and measures Engine API import -latency. The current benchmark mode, `new-payload-fcu`, submits each block with -`engine_newPayloadV4`, follows it with `engine_forkchoiceUpdatedV3`, and writes CSV artifacts for -per-block latency and aggregate throughput. +`arc-engine-bench` drives the Arc execution node over the Engine API and measures latency. Two +benchmark modes: + +- `new-payload-fcu` exercises the block-import path: it submits each recorded block with + `engine_newPayloadV4`, follows it with `engine_forkchoiceUpdatedV3`, and writes CSV artifacts for + per-block latency and aggregate throughput. +- `build-payload` exercises the block-building path: per recorded block it injects the recorded + transactions, starts a build with `engine_forkchoiceUpdatedV3`-with-attributes, waits a fixed + window, times `engine_getPayloadV5`/`V4`, then advances the chain on the recorded block. This is + the mode that benchmarks the reth payload-builder flags. ## CLI @@ -14,6 +20,7 @@ per-block latency and aggregate throughput. | --- | --- | | `arc-engine-bench prepare-payload` | Fetches a contiguous source block range and writes a local payload fixture directory with `genesis.json`, `metadata.json`, and `payloads.jsonl`. | | `arc-engine-bench new-payload-fcu` | Replays a prepared payload fixture into a target execution node with `engine_newPayloadV4` followed by `engine_forkchoiceUpdatedV3`. | +| `arc-engine-bench build-payload` | Drives the payload-builder path: inject recorded txs, `engine_forkchoiceUpdatedV3`-with-attributes, wait the build window, time `engine_getPayload`, then advance on the recorded block. | ## What You Need @@ -95,8 +102,7 @@ arc-node-execution node \ --http.port "$HTTP_PORT" \ --metrics 127.0.0.1:"$METRICS_PORT" \ --auth-ipc \ - --auth-ipc.path "$ENGINE_IPC" \ - --arc.denylist.enabled + --auth-ipc.path "$ENGINE_IPC" ``` **RPC transport:** @@ -113,8 +119,7 @@ arc-node-execution node \ --metrics 127.0.0.1:"$METRICS_PORT" \ --authrpc.addr=127.0.0.1 \ --authrpc.port="$AUTHRPC_PORT" \ - --authrpc.jwtsecret="$BENCH_DATADIR/jwt.hex" \ - --arc.denylist.enabled + --authrpc.jwtsecret="$BENCH_DATADIR/jwt.hex" ``` ## Prepare the Payload Fixture @@ -177,6 +182,56 @@ Other flags: - `--eth-rpc-timeout-ms ` sets the timeout for target Ethereum RPC requests. The default is `10000` ms. +## Run `build-payload` + +`build-payload` reuses the same fixture as `new-payload-fcu`. For each recorded block `N`, with the +target head at `N-1`, it: + +1. injects block `N`'s recorded transactions into the target mempool via `eth_sendRawTransaction`; +2. sends `engine_forkchoiceUpdatedV3` with payload attributes (recorded timestamp, prev_randao, and + fee recipient) to start a build; +3. waits a fixed build window (`--build-window-ms`); +4. calls `engine_getPayloadV5`/`V4` and times it (the headline metric); +5. commits the **recorded** block `N` via `engine_newPayloadV4` + `engine_forkchoiceUpdatedV3` to + advance the chain deterministically along real history. + +The built block from step 4 is discarded; only its metrics are kept. The chain advances on the +recorded block, so state stays on real history and is identical across flag variants. This is the +determinism guarantee and a free correctness anchor. + +**IPC transport:** + +```bash +arc-engine-bench build-payload \ + --engine-ipc "$ENGINE_IPC" \ + --target-eth-rpc-url "$TARGET_ETH_RPC_URL" \ + --payload "$PAYLOAD_DIR" \ + --build-window-ms 200 +``` + +Other flags: + +- `--build-window-ms ` is the wait between `forkchoiceUpdated`-with-attributes and + `getPayload`. Hold it constant across variants. It should be at or below the production + block-build deadline. The default is `200`. +- `--get-payload-version ` selects the `getPayload` version. `auto` (default) tries V5 + (Osaka) and falls back to V4. Pass `v4` explicitly if the auto fallback misfires on your target. +- `--disallow-tx-rejections` makes the run bail when `eth_sendRawTransaction` rejects a tx. By + default rejections are tolerated (logged and counted in `txs_rejected`), since recorded txs are + routinely rejected (already-known, base-fee too low, nonce gap). +- `--output ` and `--eth-rpc-timeout-ms ` behave as in `new-payload-fcu`. + +### Benchmarking payload-builder flags + +`build-payload` exists to measure the reth v2.2.0 payload-builder flags +(`--engine.share-execution-cache-with-payload-builder`, +`--engine.share-sparse-trie-with-payload-builder`, `--engine.suppress-persistence-during-build`). +Toggle a flag on the target node, hold `--build-window-ms` constant across runs, and compare +`get_payload_ms`. The state-root timing the flags affect is internal to the node and not observable +over the Engine API; source it from the node's slow-block log +(`--engine.slow-block-threshold 0` makes every block emit a detailed timing line) and correlate by +block number during analysis. It is intentionally not a CSV column. + ## Live Metrics From the repo root, start the monitoring stack: @@ -197,5 +252,7 @@ Each run writes to `target/engine-bench/-/` unless you p | File | Content | | --- | --- | -| `combined_latency.csv` | One row per replayed block with block metadata, `new_payload_ms`, `fcu_ms`, `total_ms`, per-block throughput, and cumulative throughput. | -| `summary.csv` | One-row summary with sample count, total gas and txs, wall-clock time, average throughput, and latency percentiles. | +| `combined_latency.csv` | (`new-payload-fcu`) One row per replayed block with block metadata, `new_payload_ms`, `fcu_ms`, `total_ms`, per-block throughput, and cumulative throughput. | +| `summary.csv` | (`new-payload-fcu`) One-row summary with sample count, total gas and txs, wall-clock time, average throughput, and latency percentiles. | +| `combined_build_latency.csv` | (`build-payload`) One row per block with recorded vs built gas/tx counts, `fcu_attrs_ms`, `get_payload_ms` (headline), `gas_fill_ratio` (built/recorded gas, the build-quality cross-check), and `txs_rejected`. | +| `build_summary.csv` | (`build-payload`) One-row summary with sample count, build window, total/average built gas and txs, wall-clock time, `get_payload_ms` and `fcu_attrs_ms` percentiles, and average `gas_fill_ratio`. | diff --git a/crates/engine-bench/src/bench/build_payload.rs b/crates/engine-bench/src/bench/build_payload.rs new file mode 100644 index 00000000..0f690d0a --- /dev/null +++ b/crates/engine-bench/src/bench/build_payload.rs @@ -0,0 +1,350 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::time::{Duration, Instant}; + +use alloy_primitives::B256; +use alloy_rpc_types_engine::{ExecutionPayloadV3, PayloadAttributes, PayloadId}; +use arc_eth_engine::engine::EngineAPI; +use arc_eth_engine::{ + ENGINE_FORKCHOICE_UPDATED_TIMEOUT, ENGINE_GET_PAYLOAD_TIMEOUT, ENGINE_NEW_PAYLOAD_TIMEOUT, +}; +use eyre::{bail, eyre, Context}; +use tracing::{debug, info}; + +use crate::bench::context::BenchContext; +use crate::bench::fixture::PayloadFixture; +use crate::bench::helpers::{duration_to_ms, fmt_hash}; +use crate::bench::new_payload_fcu::verify_target_start_state; +use crate::bench::output::{ + build_build_summary, write_csv, BuildSummaryRow, CombinedBuildLatencyRow, CsvWriter, +}; +use crate::bench::tx_submit::TxSubmitter; +use crate::cli::{BuildPayloadArgs, GetPayloadVersion}; + +const COMBINED_BUILD_LATENCY_FILE_NAME: &str = "combined_build_latency.csv"; +const BUILD_SUMMARY_FILE_NAME: &str = "build_summary.csv"; + +/// PayloadAttributes for a build on top of `parent_hash`, reusing the recorded block's +/// timestamp, prev_randao, and fee recipient. Arc has no beacon chain, so +/// parent_beacon_block_root carries the parent execution hash. +pub(crate) fn payload_attributes_from( + recorded: &ExecutionPayloadV3, + parent_hash: B256, +) -> PayloadAttributes { + let inner = &recorded.payload_inner.payload_inner; + PayloadAttributes { + timestamp: inner.timestamp, + prev_randao: inner.prev_randao, + suggested_fee_recipient: inner.fee_recipient, + withdrawals: Some(recorded.payload_inner.withdrawals.clone()), + parent_beacon_block_root: Some(parent_hash), + slot_number: None, + } +} + +pub(crate) fn use_v5_for(v: GetPayloadVersion) -> Option { + match v { + GetPayloadVersion::V5 => Some(true), + GetPayloadVersion::V4 => Some(false), + GetPayloadVersion::Auto => None, + } +} + +/// Call getPayload with the requested version. Auto tries V5 (Osaka) and falls back to V4 +/// on an unsupported-fork error. +pub(crate) async fn get_payload_with_version( + engine: &dyn EngineAPI, + payload_id: PayloadId, + version: GetPayloadVersion, +) -> eyre::Result { + match use_v5_for(version) { + Some(use_v5) => { + engine + .get_payload(payload_id, use_v5, ENGINE_GET_PAYLOAD_TIMEOUT) + .await + } + None => match engine + .get_payload(payload_id, true, ENGINE_GET_PAYLOAD_TIMEOUT) + .await + { + Ok(p) => Ok(p), + Err(e) if is_unsupported_fork_err(&e) => { + engine + .get_payload(payload_id, false, ENGINE_GET_PAYLOAD_TIMEOUT) + .await + } + Err(e) => Err(e), + }, + } +} + +/// Errors arrive as stringly eyre::Report, so match the JSON-RPC unsupported-fork code +/// (-38005) and the equivalent phrasings. +fn is_unsupported_fork_err(e: &eyre::Report) -> bool { + let s = e.to_string().to_lowercase(); + s.contains("-38005") + || s.contains("unsupported fork") + || s.contains("method not found") + || s.contains("getpayloadv5") +} + +/// Inject the recorded transactions into the target mempool, returning the rejected count. +/// With `disallow_rejections`, the first rejection aborts the run. +async fn inject_transactions( + submitter: &TxSubmitter, + payload: &ExecutionPayloadV3, + block_number: u64, + disallow_rejections: bool, +) -> eyre::Result { + let mut rejected = 0u64; + for raw in &payload.payload_inner.payload_inner.transactions { + if let Err(e) = submitter.send_raw_transaction(raw).await { + if disallow_rejections { + bail!("tx rejected for block {block_number}: {e}"); + } + rejected = rejected.saturating_add(1); + debug!(block_number, "tx rejected: {e}"); + } + } + Ok(rejected) +} + +/// Built throughput in MGas/s over the build window plus getPayload latency; 0 when the +/// window is non-positive. +fn built_mgas_per_s(built_gas: u64, window_plus_get_s: f64) -> f64 { + if window_plus_get_s > 0.0 { + built_gas as f64 / window_plus_get_s / 1_000_000.0 + } else { + 0.0 + } +} + +/// Ratio of built gas to recorded gas; 0 when the recorded block used no gas. +fn gas_fill_ratio(built_gas: u64, recorded_gas: u64) -> f64 { + if recorded_gas > 0 { + built_gas as f64 / recorded_gas as f64 + } else { + 0.0 + } +} + +pub async fn run(args: BuildPayloadArgs) -> eyre::Result<()> { + let context = BenchContext::new(&args.common, "build-payload")?; + + info!( + payload_dir = %args.payload.display(), + target_eth_rpc_url = args.target_eth_rpc_url, + engine = %context.transport(), + build_window_ms = args.build_window_ms, + output_dir = %context.output_dir().display(), + "running build-payload benchmark" + ); + + let eth_rpc = context.ethereum_rpc(&args.target_eth_rpc_url, "target eth rpc")?; + let engine = context.engine().await?; + let mut fixture = PayloadFixture::open(args.payload.as_path())?; + let metadata = fixture.metadata().clone(); + verify_target_start_state(ð_rpc, &metadata).await?; + + info!( + from_block = metadata.from_block, + to_block = metadata.to_block, + payload_count = metadata.payload_count, + "starting build-payload loop" + ); + + let submitter = TxSubmitter::new( + args.target_eth_rpc_url.clone(), + args.common.eth_rpc_timeout_ms, + )?; + let mut parent_hash = metadata.expected_parent.block_hash; + + let started = Instant::now(); + let row_capacity = metadata.payload_count.min(usize::MAX as u64) as usize; + let mut rows: Vec = Vec::with_capacity(row_capacity); + let mut csv = CsvWriter::new(&context.output_dir().join(COMBINED_BUILD_LATENCY_FILE_NAME))?; + + while let Some(payload) = fixture.next_payload()? { + let block_number = payload.payload_inner.payload_inner.block_number; + let recorded_gas = payload.payload_inner.payload_inner.gas_used; + let recorded_tx_count = payload.payload_inner.payload_inner.transactions.len() as u64; + let recorded_block_hash = payload.payload_inner.payload_inner.block_hash; + + let rejected = inject_transactions( + &submitter, + &payload, + block_number, + args.disallow_tx_rejections, + ) + .await?; + + // Start a build on the parent using the recorded block's attributes. + let attrs = payload_attributes_from(&payload, parent_hash); + let t0 = Instant::now(); + let fcu = engine + .forkchoice_updated(parent_hash, Some(attrs), ENGINE_FORKCHOICE_UPDATED_TIMEOUT) + .await + .wrap_err_with(|| format!("FCU-with-attrs failed at block {block_number}"))?; + let fcu_attrs_ms = duration_to_ms(t0.elapsed()); + if !fcu.payload_status.is_valid() { + bail!( + "FCU-attrs non-valid at block {block_number}: {:?}", + fcu.payload_status + ); + } + let payload_id = fcu + .payload_id + .ok_or_else(|| eyre!("no payload_id from FCU-with-attrs at block {block_number}"))?; + + tokio::time::sleep(Duration::from_millis(args.build_window_ms)).await; + + let t1 = Instant::now(); + let built = get_payload_with_version(engine.as_ref(), payload_id, args.get_payload_version) + .await + .wrap_err_with(|| format!("getPayload failed at block {block_number}"))?; + let get_payload_ms = duration_to_ms(t1.elapsed()); + let built_gas = built.payload_inner.payload_inner.gas_used; + let built_tx_count = built.payload_inner.payload_inner.transactions.len() as u64; + let built_hash = built.payload_inner.payload_inner.block_hash; + + // The built block is discarded; committing the recorded block keeps state on real + // history and identical across flag variants. + let status = engine + .new_payload( + &payload, + Vec::new(), + parent_hash, + ENGINE_NEW_PAYLOAD_TIMEOUT, + ) + .await + .wrap_err_with(|| format!("newPayload(recorded) failed at block {block_number}"))?; + if !status.is_valid() { + bail!("recorded newPayload non-valid at block {block_number}: {status:?}"); + } + let fcu2 = engine + .forkchoice_updated(recorded_block_hash, None, ENGINE_FORKCHOICE_UPDATED_TIMEOUT) + .await + .wrap_err_with(|| format!("advance FCU failed at block {block_number}"))?; + if !fcu2.payload_status.is_valid() { + bail!( + "advance FCU non-valid at block {block_number}: {:?}", + fcu2.payload_status + ); + } + + let elapsed_ms = duration_to_ms(started.elapsed()); + let window_plus_get_s = (args.build_window_ms as f64 + get_payload_ms) / 1000.0; + let row = CombinedBuildLatencyRow { + block_number, + parent_hash: fmt_hash(parent_hash), + recorded_gas, + recorded_tx_count, + built_block_hash: fmt_hash(built_hash), + built_gas, + built_tx_count, + txs_submitted: recorded_tx_count, + txs_rejected: rejected, + fcu_attrs_ms, + get_payload_ms, + build_window_ms: args.build_window_ms as f64, + elapsed_ms, + built_mgas_per_s: built_mgas_per_s(built_gas, window_plus_get_s), + gas_fill_ratio: gas_fill_ratio(built_gas, recorded_gas), + }; + csv.write_row(&row)?; + rows.push(row); + + parent_hash = recorded_block_hash; + info!( + block_number, + built_gas, recorded_gas, get_payload_ms, "built + advanced" + ); + } + + csv.finish()?; + let wall_clock = started.elapsed(); + let summary: BuildSummaryRow = + build_build_summary(&rows, wall_clock, args.build_window_ms as f64); + write_csv( + &context.output_dir().join(BUILD_SUMMARY_FILE_NAME), + &[summary], + )?; + + info!( + samples = rows.len(), + wall_clock_ms = duration_to_ms(wall_clock), + output_dir = %context.output_dir().display(), + "build-payload benchmark complete" + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, b256, Address}; + use serde_json::json; + + fn sample_payload(ts: u64, fee: Address, randao: B256) -> ExecutionPayloadV3 { + let mut p: ExecutionPayloadV3 = serde_json::from_value(json!({ + "parentHash": format!("0x{}", "00".repeat(32)), + "feeRecipient": format!("0x{}", "00".repeat(20)), + "stateRoot": format!("0x{}", "01".repeat(32)), + "receiptsRoot": format!("0x{}", "02".repeat(32)), + "logsBloom": format!("0x{}", "00".repeat(256)), + "prevRandao": format!("0x{}", "00".repeat(32)), + "blockNumber": "0x1", + "gasLimit": "0x1c9c380", + "gasUsed": "0x3e8", + "timestamp": "0x1", + "extraData": "0x", + "baseFeePerGas": "0x1", + "blockHash": format!("0x{}", "03".repeat(32)), + "transactions": [], + "withdrawals": [], + "blobGasUsed": "0x0", + "excessBlobGas": "0x0" + })) + .expect("valid payload json"); + p.payload_inner.payload_inner.timestamp = ts; + p.payload_inner.payload_inner.fee_recipient = fee; + p.payload_inner.payload_inner.prev_randao = randao; + p + } + + #[test] + fn version_maps_to_use_v5_flag() { + assert_eq!(use_v5_for(GetPayloadVersion::V5), Some(true)); + assert_eq!(use_v5_for(GetPayloadVersion::V4), Some(false)); + assert_eq!(use_v5_for(GetPayloadVersion::Auto), None); + } + + #[test] + fn attributes_copy_recorded_fields() { + let fee = address!("0x65E0a200006D4FF91bD59F9694220dafc49dbBC1"); + let randao = b256!("0x1111111111111111111111111111111111111111111111111111111111111111"); + let parent = b256!("0x2222222222222222222222222222222222222222222222222222222222222222"); + let p = sample_payload(1234, fee, randao); + let attrs = payload_attributes_from(&p, parent); + assert_eq!(attrs.timestamp, 1234); + assert_eq!(attrs.suggested_fee_recipient, fee); + assert_eq!(attrs.prev_randao, randao); + assert_eq!(attrs.parent_beacon_block_root, Some(parent)); + assert!(attrs.withdrawals.is_some()); + } +} diff --git a/crates/engine-bench/src/bench/mod.rs b/crates/engine-bench/src/bench/mod.rs index 545223e4..51260cd2 100644 --- a/crates/engine-bench/src/bench/mod.rs +++ b/crates/engine-bench/src/bench/mod.rs @@ -16,16 +16,19 @@ use crate::cli::Command; +mod build_payload; mod context; mod fixture; mod helpers; pub mod new_payload_fcu; mod output; mod prepare_payload; +mod tx_submit; pub async fn run(command: Command) -> eyre::Result<()> { match command { Command::PreparePayload(args) => prepare_payload::run(args).await, Command::NewPayloadFcu(args) => new_payload_fcu::run(args).await, + Command::BuildPayload(args) => build_payload::run(args).await, } } diff --git a/crates/engine-bench/src/bench/new_payload_fcu.rs b/crates/engine-bench/src/bench/new_payload_fcu.rs index 5aa284cd..318ea6c6 100644 --- a/crates/engine-bench/src/bench/new_payload_fcu.rs +++ b/crates/engine-bench/src/bench/new_payload_fcu.rs @@ -24,7 +24,11 @@ use super::{ }, }; use crate::cli::NewPayloadFcuArgs; -use arc_eth_engine::{json_structures::ExecutionBlock, rpc::ethereum_rpc::EthereumRPC}; +use alloy_rpc_types_eth::BlockNumberOrTag; +use arc_eth_engine::{ + json_structures::ExecutionBlock, rpc::ethereum_rpc::EthereumRPC, + ENGINE_FORKCHOICE_UPDATED_TIMEOUT, ENGINE_NEW_PAYLOAD_TIMEOUT, +}; use eyre::{bail, Context}; use std::time::Instant; use tracing::info; @@ -69,7 +73,12 @@ pub async fn run(args: NewPayloadFcuArgs) -> eyre::Result<()> { let start = Instant::now(); let status = engine - .new_payload(&payload, Vec::new(), parent_hash) + .new_payload( + &payload, + Vec::new(), + parent_hash, + ENGINE_NEW_PAYLOAD_TIMEOUT, + ) .await .wrap_err_with(|| format!("engine_newPayloadV4 failed for block {block_number}"))?; let new_payload_latency = start.elapsed(); @@ -79,7 +88,7 @@ pub async fn run(args: NewPayloadFcuArgs) -> eyre::Result<()> { } let fcu_result = engine - .forkchoice_updated(block_hash, None) + .forkchoice_updated(block_hash, None, ENGINE_FORKCHOICE_UPDATED_TIMEOUT) .await .wrap_err_with(|| { format!("engine_forkchoiceUpdatedV3 failed for block {block_number}") @@ -132,12 +141,12 @@ pub async fn run(args: NewPayloadFcuArgs) -> eyre::Result<()> { Ok(()) } -async fn verify_target_start_state( +pub(crate) async fn verify_target_start_state( target_rpc: &EthereumRPC, metadata: &PayloadFixtureMetadata, ) -> eyre::Result<()> { let target_latest_block = target_rpc - .get_block_by_number("latest") + .get_block_by_number(BlockNumberOrTag::Latest) .await .wrap_err("failed to fetch latest block from target node")? .ok_or_else(|| eyre::eyre!("latest block not found on target node"))?; diff --git a/crates/engine-bench/src/bench/output.rs b/crates/engine-bench/src/bench/output.rs index dca2ee9a..6cf950d9 100644 --- a/crates/engine-bench/src/bench/output.rs +++ b/crates/engine-bench/src/bench/output.rs @@ -65,6 +65,48 @@ pub(crate) struct SummaryRow { pub p99_total_ms: f64, } +#[derive(Debug, Clone, Serialize)] +pub(crate) struct CombinedBuildLatencyRow { + pub block_number: u64, + pub parent_hash: String, + pub recorded_gas: u64, + pub recorded_tx_count: u64, + pub built_block_hash: String, + pub built_gas: u64, + pub built_tx_count: u64, + pub txs_submitted: u64, + pub txs_rejected: u64, + pub fcu_attrs_ms: f64, + pub get_payload_ms: f64, + pub build_window_ms: f64, + pub elapsed_ms: f64, + pub built_mgas_per_s: f64, + pub gas_fill_ratio: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct BuildSummaryRow { + pub mode: String, + pub samples: u64, + pub build_window_ms: f64, + pub total_built_gas: u64, + pub total_built_txs: u64, + pub total_recorded_gas: u64, + pub wall_clock_ms: f64, + pub avg_fcu_attrs_ms: f64, + pub avg_get_payload_ms: f64, + pub p50_get_payload_ms: f64, + pub p95_get_payload_ms: f64, + pub p99_get_payload_ms: f64, + pub p50_fcu_attrs_ms: f64, + pub p95_fcu_attrs_ms: f64, + pub p99_fcu_attrs_ms: f64, + pub avg_built_gas: f64, + pub avg_built_txs: f64, + pub avg_gas_fill_ratio: f64, + pub avg_built_mgas_per_s: f64, +} + pub(crate) trait BenchmarkRow { fn gas_used(&self) -> u64; fn tx_count(&self) -> u64; @@ -205,6 +247,46 @@ pub(crate) fn build_summary( }) } +pub(crate) fn build_build_summary( + rows: &[CombinedBuildLatencyRow], + wall_clock: Duration, + build_window_ms: f64, +) -> BuildSummaryRow { + let samples = rows.len() as u64; + let mut gp: Vec = rows.iter().map(|r| r.get_payload_ms).collect(); + let mut fa: Vec = rows.iter().map(|r| r.fcu_attrs_ms).collect(); + sort_f64(&mut gp); + sort_f64(&mut fa); + + let total_built_gas: u64 = rows.iter().map(|r| r.built_gas).sum(); + let total_built_txs: u64 = rows.iter().map(|r| r.built_tx_count).sum(); + let total_recorded_gas: u64 = rows.iter().map(|r| r.recorded_gas).sum(); + let n = samples.max(1) as f64; + let wall_ms = duration_to_ms(wall_clock); + + BuildSummaryRow { + mode: "build-payload".to_owned(), + samples, + build_window_ms, + total_built_gas, + total_built_txs, + total_recorded_gas, + wall_clock_ms: wall_ms, + avg_fcu_attrs_ms: fa.iter().sum::() / n, + avg_get_payload_ms: gp.iter().sum::() / n, + p50_get_payload_ms: percentile_sorted(&gp, 0.50), + p95_get_payload_ms: percentile_sorted(&gp, 0.95), + p99_get_payload_ms: percentile_sorted(&gp, 0.99), + p50_fcu_attrs_ms: percentile_sorted(&fa, 0.50), + p95_fcu_attrs_ms: percentile_sorted(&fa, 0.95), + p99_fcu_attrs_ms: percentile_sorted(&fa, 0.99), + avg_built_gas: total_built_gas as f64 / n, + avg_built_txs: total_built_txs as f64 / n, + avg_gas_fill_ratio: rows.iter().map(|r| r.gas_fill_ratio).sum::() / n, + avg_built_mgas_per_s: throughput_mgas_per_s(total_built_gas, wall_clock), + } +} + fn average(values: &[f64]) -> Option { (!values.is_empty()).then(|| values.iter().sum::() / values.len() as f64) } @@ -241,6 +323,35 @@ fn percentile(mut values: Vec, quantile: f64) -> f64 { mod tests { use super::*; + #[test] + fn build_summary_computes_percentiles() { + let rows: Vec = (1..=5) + .map(|i| CombinedBuildLatencyRow { + block_number: i, + parent_hash: "0x".into(), + recorded_gas: 100, + recorded_tx_count: 10, + built_block_hash: "0x".into(), + built_gas: 90, + built_tx_count: 9, + txs_submitted: 10, + txs_rejected: 1, + fcu_attrs_ms: i as f64, + get_payload_ms: (i * 2) as f64, + build_window_ms: 200.0, + elapsed_ms: 0.0, + built_mgas_per_s: 0.0, + gas_fill_ratio: 0.9, + }) + .collect(); + + let s = build_build_summary(&rows, Duration::from_millis(50), 200.0); + assert_eq!(s.samples, 5); + assert_eq!(s.total_built_gas, 450); + assert_eq!(s.p50_get_payload_ms, 6.0); // median of 2,4,6,8,10 + assert!((s.avg_gas_fill_ratio - 0.9).abs() < 1e-9); + } + #[test] fn percentile_interpolates_between_samples() { let actual = percentile(vec![10.0, 20.0, 30.0, 40.0], 0.75); diff --git a/crates/engine-bench/src/bench/prepare_payload.rs b/crates/engine-bench/src/bench/prepare_payload.rs index 69c59201..cecaafbc 100644 --- a/crates/engine-bench/src/bench/prepare_payload.rs +++ b/crates/engine-bench/src/bench/prepare_payload.rs @@ -20,6 +20,7 @@ use super::{ helpers::fmt_hash, }; use crate::cli::PreparePayloadArgs; +use alloy_rpc_types_eth::BlockNumberOrTag; use arc_eth_engine::{engine::EthereumAPI, rpc::ethereum_rpc::EthereumRPC}; use arc_execution_config::chainspec::ArcChainSpecParser; use eyre::{bail, Context}; @@ -83,7 +84,7 @@ async fn fetch_expected_parent( .checked_sub(1) .ok_or_else(|| eyre::eyre!("from_block must be greater than 0"))?; let expected_parent_block = source_rpc - .get_block_by_number(&format!("0x{expected_parent_block_number:x}")) + .get_block_by_number(BlockNumberOrTag::Number(expected_parent_block_number)) .await .wrap_err_with(|| { format!("failed to fetch source parent block {expected_parent_block_number}") @@ -117,7 +118,7 @@ async fn write_payload_fixture( .saturating_sub(1) .min(to); let block_numbers = (chunk_start..=chunk_end) - .map(|block_number| format!("0x{block_number:x}")) + .map(BlockNumberOrTag::Number) .collect::>(); let chunk = ::get_execution_payloads(source_rpc, &block_numbers) diff --git a/crates/engine-bench/src/bench/tx_submit.rs b/crates/engine-bench/src/bench/tx_submit.rs new file mode 100644 index 00000000..911e7997 --- /dev/null +++ b/crates/engine-bench/src/bench/tx_submit.rs @@ -0,0 +1,82 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloy_primitives::Bytes; +use eyre::{eyre, Result}; +use serde_json::{json, Value}; +use std::{cell::Cell, time::Duration}; + +pub(crate) fn send_raw_tx_body(raw_tx: &Bytes, id: u64) -> Value { + json!({"jsonrpc": "2.0", "id": id, "method": "eth_sendRawTransaction", "params": [raw_tx]}) +} + +/// Submits raw EIP-2718 transactions to the target node's eth RPC over JSON-RPC. +pub(crate) struct TxSubmitter { + client: reqwest::Client, + url: String, + next_id: Cell, +} + +impl TxSubmitter { + pub(crate) fn new(url: String, timeout_ms: u64) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .build()?; + Ok(Self { + client, + url, + next_id: Cell::new(1), + }) + } + + /// Submit one raw EIP-2718 transaction. Returns the tx hash on success; Err carries the + /// JSON-RPC error. + pub(crate) async fn send_raw_transaction(&self, raw_tx: &Bytes) -> Result { + let id = self.next_id.get(); + self.next_id.set(id.saturating_add(1)); + let resp: Value = self + .client + .post(&self.url) + .json(&send_raw_tx_body(raw_tx, id)) + .send() + .await? + .json() + .await?; + if let Some(err) = resp.get("error") { + return Err(eyre!("eth_sendRawTransaction error: {err}")); + } + Ok(resp + .get("result") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Bytes; + + #[test] + fn builds_send_raw_tx_request_body() { + let raw = Bytes::from(vec![0x02, 0xaa, 0xbb]); + let body = send_raw_tx_body(&raw, 7); + assert_eq!(body["method"], "eth_sendRawTransaction"); + assert_eq!(body["id"], 7); + assert_eq!(body["params"][0], "0x02aabb"); + } +} diff --git a/crates/engine-bench/src/cli.rs b/crates/engine-bench/src/cli.rs index 11b31be1..8e9c9921 100644 --- a/crates/engine-bench/src/cli.rs +++ b/crates/engine-bench/src/cli.rs @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use std::path::PathBuf; #[derive(Debug, Parser)] @@ -35,6 +35,16 @@ pub enum Command { PreparePayload(PreparePayloadArgs), /// Replay historical blocks into a target Arc node with newPayload + forkchoiceUpdated. NewPayloadFcu(NewPayloadFcuArgs), + /// Build payloads via forkchoiceUpdated-with-attributes -> getPayload, advancing + /// the chain on recorded blocks. Benchmarks the payload-builder path. + BuildPayload(BuildPayloadArgs), +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum GetPayloadVersion { + Auto, + V5, + V4, } #[derive(Debug, Args, Clone)] @@ -92,3 +102,62 @@ pub struct NewPayloadFcuArgs { #[arg(long, value_name = "PAYLOAD_DIR")] pub payload: PathBuf, } + +#[derive(Debug, Args, Clone)] +pub struct BuildPayloadArgs { + #[command(flatten)] + pub common: CommonArgs, + + /// Target node's regular Ethereum RPC endpoint (eth_sendRawTransaction + head checks). + #[arg(long, value_name = "TARGET_ETH_RPC_URL")] + pub target_eth_rpc_url: String, + + /// Fixture directory (genesis.json, metadata.json, payloads.jsonl). + #[arg(long, value_name = "PAYLOAD_DIR")] + pub payload: PathBuf, + + /// Milliseconds to wait between forkchoiceUpdated-with-attributes and getPayload. + /// The build window; hold constant across variants. + #[arg(long, value_name = "MILLISECONDS", default_value_t = 200, value_parser = clap::value_parser!(u64).range(1..))] + pub build_window_ms: u64, + + /// getPayload version: auto (try V5, fall back to V4), v5, or v4. + #[arg(long, value_enum, default_value_t = GetPayloadVersion::Auto)] + pub get_payload_version: GetPayloadVersion, + + /// Bail when eth_sendRawTransaction rejects a tx. By default rejections are tolerated + /// (logged + counted in txs_rejected), since recorded txs are routinely rejected + /// (already-known, base-fee too low, nonce gaps). + #[arg(long)] + pub disallow_tx_rejections: bool, +} + +#[cfg(test)] +mod build_payload_cli_tests { + use super::*; + use clap::Parser; + + #[test] + fn parses_build_payload_with_ipc() { + let cli = Cli::parse_from([ + "arc-engine-bench", + "build-payload", + "--engine-ipc", + "/tmp/reth.ipc", + "--target-eth-rpc-url", + "http://127.0.0.1:7545", + "--payload", + "/data/fixture", + "--build-window-ms", + "250", + ]); + match cli.command { + Command::BuildPayload(a) => { + assert_eq!(a.build_window_ms, 250); + assert_eq!(a.payload.to_str().unwrap(), "/data/fixture"); + assert!(matches!(a.get_payload_version, GetPayloadVersion::Auto)); + } + _ => panic!("expected BuildPayload"), + } + } +} diff --git a/crates/eth-engine/Cargo.toml b/crates/eth-engine/Cargo.toml index 7fc02379..3acce818 100644 --- a/crates/eth-engine/Cargo.toml +++ b/crates/eth-engine/Cargo.toml @@ -36,7 +36,7 @@ jsonrpsee-types = { workspace = true } jsonwebtoken = { workspace = true } malachitebft-core-types = { workspace = true } mockall = { workspace = true, optional = true } -reqwest = { workspace = true, features = ["blocking", "json", "stream", "rustls-tls", "native-tls-vendored"] } +reqwest = { workspace = true, features = ["blocking", "json", "stream", "rustls", "native-tls-vendored"] } reth-chainspec = { workspace = true } reth-ipc = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -55,6 +55,7 @@ reth-node-builder = { workspace = true, features = ["test-utils"] } reth-tasks = { workspace = true } rstest = { workspace = true } tempfile = "3.8" +tokio = { workspace = true, features = ["test-util"] } url = { workspace = true } wiremock = { workspace = true } diff --git a/crates/eth-engine/src/abi_utils.rs b/crates/eth-engine/src/abi_utils.rs index 5f9a5ab8..43735c2b 100644 --- a/crates/eth-engine/src/abi_utils.rs +++ b/crates/eth-engine/src/abi_utils.rs @@ -151,6 +151,7 @@ pub fn abi_decode_consensus_params(result: Vec) -> eyre::Result Self { + Self( + Instant::now() + .checked_add(budget) + .expect("deadline must fit in Instant; consensus budgets are bounded"), + ) + } + + /// Absolute deadline for the outer operation that owns this budget. + pub fn timeout_at(self) -> Instant { + self.0 + } + + /// Timeout for a single Engine API call: `floor`, or the remaining + /// budget plus `leeway` when larger. + /// + /// Budgeted paths also wrap the sequence in an outer + /// `timeout_at(deadline.timeout_at())` (see `on_get_value`). That outer + /// timeout fails benignly (proposer skips the round, `Ok(None)`); a + /// per-call timeout fails hard (aborts the consensus app), so it must + /// stay strictly non-binding. `floor` guarantees that while the budget + /// is below it; once the budget exceeds `floor`, `leeway` keeps the + /// per-call deadline a timer tick past the outer one, avoiding a + /// same-slot tie at tokio's ~1ms granularity. `floor` also bounds calls + /// when no outer timeout is set. + pub fn call_timeout(self, floor: Duration) -> Duration { + let remaining = self.0.saturating_duration_since(Instant::now()); + floor.max(remaining.saturating_add(CALL_TIMEOUT_LEEWAY)) + } + + /// Resolve the timeout for a single Engine API call: `floor` when no + /// deadline is active (unbudgeted paths), otherwise + /// [`EngineDeadline::call_timeout`]. + pub fn resolve(deadline: Option, floor: Duration) -> Duration { + deadline.map_or(floor, |d| d.call_timeout(floor)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FLOOR: Duration = Duration::from_secs(8); + + #[test] + fn floor_wins_when_remaining_is_smaller() { + let deadline = EngineDeadline::within(Duration::from_secs(1)); + assert_eq!(deadline.call_timeout(FLOOR), FLOOR); + } + + #[test] + fn remaining_wins_when_larger_than_floor() { + let deadline = EngineDeadline::within(Duration::from_secs(20)); + let timeout = deadline.call_timeout(FLOOR); + assert!(timeout > FLOOR); + assert!(timeout <= Duration::from_secs(20).saturating_add(CALL_TIMEOUT_LEEWAY)); + } + + #[tokio::test(start_paused = true)] + async fn adds_leeway_when_remaining_exceeds_floor() { + // Paused clock: no time elapses between `within` and the call, so + // `remaining` is exactly the budget and the leeway is observable. + // A bare `max(floor, remaining)` would return exactly 20s here. + let deadline = EngineDeadline::within(Duration::from_secs(20)); + assert_eq!( + deadline.call_timeout(FLOOR), + Duration::from_secs(20).saturating_add(CALL_TIMEOUT_LEEWAY), + ); + } + + #[test] + fn saturates_to_floor_when_budget_exhausted() { + let deadline = EngineDeadline::within(Duration::ZERO); + assert_eq!(deadline.call_timeout(FLOOR), FLOOR); + } + + #[test] + fn successive_calls_never_grow() { + let deadline = EngineDeadline::within(Duration::from_secs(20)); + let first = deadline.call_timeout(FLOOR); + let second = deadline.call_timeout(FLOOR); + assert!(second <= first); + } + + #[test] + fn resolve_uses_floor_when_no_deadline() { + assert_eq!(EngineDeadline::resolve(None, FLOOR), FLOOR); + } + + #[test] + fn resolve_extends_floor_with_active_deadline() { + let deadline = EngineDeadline::within(Duration::from_secs(20)); + assert!(EngineDeadline::resolve(Some(deadline), FLOOR) > FLOOR); + } +} diff --git a/crates/eth-engine/src/engine.rs b/crates/eth-engine/src/engine.rs index 180116a4..e46ddce2 100644 --- a/crates/eth-engine/src/engine.rs +++ b/crates/eth-engine/src/engine.rs @@ -20,13 +20,14 @@ use std::{ ops::Deref, path::Path, sync::{Arc, OnceLock}, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::sync::watch; use async_trait::async_trait; use tracing::debug; +use alloy_rpc_types::BlockNumberOrTag; use alloy_rpc_types_engine::{ ExecutionPayloadV3, ForkchoiceUpdated, PayloadAttributes, PayloadId as AlloyPayloadId, PayloadStatus, PayloadStatusEnum, @@ -36,6 +37,10 @@ use alloy_rpc_types_txpool::{TxpoolInspect, TxpoolStatus}; use arc_consensus_types::{Address, BlockHash, ConsensusParams, ValidatorSet, B256}; use crate::capabilities::{check_capabilities, EngineCapabilities}; +use crate::constants::{ + ENGINE_FORKCHOICE_UPDATED_TIMEOUT, ENGINE_GET_PAYLOAD_TIMEOUT, ENGINE_NEW_PAYLOAD_TIMEOUT, +}; +use crate::deadline::EngineDeadline; use crate::ipc::{engine_ipc::EngineIPC, ethereum_ipc::EthereumIPC}; use crate::json_structures::ExecutionBlock; use crate::rpc::{engine_rpc::EngineRpc, ethereum_rpc::EthereumRPC}; @@ -56,24 +61,33 @@ pub trait EngineAPI: Send + Sync { /// Exchange capabilities with the engine. async fn exchange_capabilities(&self) -> eyre::Result; /// Set the latest forkchoice state. + /// `timeout` bounds this single request; resolved by [`Engine`] from the + /// static floor and any active consensus deadline. async fn forkchoice_updated( &self, head_block_hash: BlockHash, maybe_payload_attributes: Option, + timeout: Duration, ) -> eyre::Result; /// Get a payload by its ID. /// When `use_v5` is true, uses `engine_getPayloadV5` (Osaka); otherwise uses V4. + /// `timeout` bounds this single request; resolved by [`Engine`] from the + /// static floor and any active consensus deadline. async fn get_payload( &self, payload_id: AlloyPayloadId, use_v5: bool, + timeout: Duration, ) -> eyre::Result; /// Notify that a new payload has been created. + /// `timeout` bounds this single request; resolved by [`Engine`] from the + /// static floor and any active consensus deadline. async fn new_payload( &self, execution_payload: &ExecutionPayloadV3, versioned_hashes: Vec, parent_block_hash: BlockHash, + timeout: Duration, ) -> eyre::Result; } @@ -84,17 +98,21 @@ pub trait EthereumAPI: Send + Sync { async fn get_chain_id(&self) -> eyre::Result; /// Get the genesis block. async fn get_genesis_block(&self) -> eyre::Result; - /// Get the active validator set at a specific block height. - async fn get_active_validator_set(&self, block_height: u64) -> eyre::Result; + /// Get the validator set that signs at the given consensus height. + /// Queries the registry at `consensus_height - 1` (saturating), because + /// the set active at height H is the one committed after executing H-1. + async fn get_signing_validator_set(&self, consensus_height: u64) -> eyre::Result; /// Get the consensus parameters at a specific block height. async fn get_consensus_params(&self, block_height: u64) -> eyre::Result; - /// Get a block by its number. - async fn get_block_by_number(&self, block_number: &str) - -> eyre::Result>; + /// Get a block by its number or tag. + async fn get_block_by_number( + &self, + block_number: BlockNumberOrTag, + ) -> eyre::Result>; /// Get multiple full payloads. async fn get_execution_payloads( &self, - block_numbers: &[String], + block_numbers: &[BlockNumberOrTag], ) -> eyre::Result>>; /// Get the status of the transaction pool. async fn txpool_status(&self) -> eyre::Result; @@ -328,7 +346,10 @@ impl Inner { let ForkchoiceUpdated { payload_status, payload_id, - } = self.api.forkchoice_updated(head_block_hash, None).await?; + } = self + .api + .forkchoice_updated(head_block_hash, None, ENGINE_FORKCHOICE_UPDATED_TIMEOUT) + .await?; match (payload_status.status, payload_id) { (PayloadStatusEnum::Valid, Some(payload_id)) => Err(eyre!( "When setting latest forkchoice, payload ID should be None, got: {:?}", @@ -357,11 +378,14 @@ impl Inner { /// - latest_block: The latest block to generate a new block on top of. /// - timestamp: Unix timestamp for when the payload is expected to be executed. /// It should be greater than or equal to that of forkchoiceState.headBlockHash. + /// - deadline: Consensus budget for the whole sequence; extends the + /// per-call timeout floors when set (proposer path). pub async fn generate_block( &self, latest_block: &ExecutionBlock, timestamp: u64, suggested_fee_recipient: &Address, + deadline: Option, ) -> eyre::Result { debug!("🟠 Generating block on top of {}", latest_block.block_hash); @@ -388,6 +412,9 @@ impl Inner { // Cannot be None in V3. Arc has no beacon chain, so we use the // execution block hash as parent_beacon_block_root. parent_beacon_block_root: Some(block_hash), + + // EIP-7843 beacon slot number — Arc has no beacon chain. + slot_number: None, }; // Build a new block on top of parent_hash // @@ -403,7 +430,11 @@ impl Inner { payload_id, } = self .api - .forkchoice_updated(block_hash, Some(payload_attributes)) + .forkchoice_updated( + block_hash, + Some(payload_attributes), + EngineDeadline::resolve(deadline, ENGINE_FORKCHOICE_UPDATED_TIMEOUT), + ) .await .wrap_err_with(|| { format!("generate_block: forkchoice_updated failed; last_block {block_hash}") @@ -428,7 +459,10 @@ impl Inner { // Complete ExecutionPayloadV3 with all transactions and computed state // See https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md#block-proposal let use_v5 = self.use_v5(timestamp); - match self.api.get_payload(payload_id, use_v5).await { + // Resolved after forkchoice_updated, so time spent there + // shrinks what get_payload may claim from the budget. + let timeout = EngineDeadline::resolve(deadline, ENGINE_GET_PAYLOAD_TIMEOUT); + match self.api.get_payload(payload_id, use_v5, timeout).await { Ok(payload) => Ok(payload), Err(e) => Err(e).wrap_err_with(|| { format!("generate_block: get_payload failed; payload_id {payload_id}") @@ -451,10 +485,13 @@ impl Inner { /// Notify that a new block has been created. /// - execution_payload: The execution payload /// - versioned_hashes: The hashes of the blobs in the execution payload. + /// - deadline: Consensus budget; extends the per-call timeout floor when + /// set (proposer self-validation path). pub async fn notify_new_block( &self, execution_payload: &ExecutionPayloadV3, versioned_hashes: Vec, + deadline: Option, ) -> eyre::Result { let parent_block_hash = execution_payload.payload_inner.payload_inner.parent_hash; // Validate the block I just received. @@ -468,7 +505,12 @@ impl Inner { // // Hopefully skips re-execution and just moves the pre-computed state to "validated payloads pool" on a proposer. self.api - .new_payload(execution_payload, versioned_hashes, parent_block_hash) + .new_payload( + execution_payload, + versioned_hashes, + parent_block_hash, + EngineDeadline::resolve(deadline, ENGINE_NEW_PAYLOAD_TIMEOUT), + ) .await } } @@ -486,9 +528,10 @@ where &self, head_block_hash: BlockHash, maybe_payload_attributes: Option, + timeout: Duration, ) -> eyre::Result { (**self) - .forkchoice_updated(head_block_hash, maybe_payload_attributes) + .forkchoice_updated(head_block_hash, maybe_payload_attributes, timeout) .await } @@ -496,8 +539,9 @@ where &self, payload_id: AlloyPayloadId, use_v5: bool, + timeout: Duration, ) -> eyre::Result { - (**self).get_payload(payload_id, use_v5).await + (**self).get_payload(payload_id, use_v5, timeout).await } async fn new_payload( @@ -505,9 +549,15 @@ where execution_payload: &ExecutionPayloadV3, versioned_hashes: Vec, parent_block_hash: BlockHash, + timeout: Duration, ) -> eyre::Result { (**self) - .new_payload(execution_payload, versioned_hashes, parent_block_hash) + .new_payload( + execution_payload, + versioned_hashes, + parent_block_hash, + timeout, + ) .await } } @@ -522,9 +572,10 @@ impl EngineAPI for Box { &self, head_block_hash: BlockHash, maybe_payload_attributes: Option, + timeout: Duration, ) -> eyre::Result { (**self) - .forkchoice_updated(head_block_hash, maybe_payload_attributes) + .forkchoice_updated(head_block_hash, maybe_payload_attributes, timeout) .await } @@ -532,8 +583,9 @@ impl EngineAPI for Box { &self, payload_id: AlloyPayloadId, use_v5: bool, + timeout: Duration, ) -> eyre::Result { - (**self).get_payload(payload_id, use_v5).await + (**self).get_payload(payload_id, use_v5, timeout).await } async fn new_payload( @@ -541,9 +593,15 @@ impl EngineAPI for Box { execution_payload: &ExecutionPayloadV3, versioned_hashes: Vec, parent_block_hash: BlockHash, + timeout: Duration, ) -> eyre::Result { (**self) - .new_payload(execution_payload, versioned_hashes, parent_block_hash) + .new_payload( + execution_payload, + versioned_hashes, + parent_block_hash, + timeout, + ) .await } } @@ -561,8 +619,8 @@ where (**self).get_genesis_block().await } - async fn get_active_validator_set(&self, block_height: u64) -> eyre::Result { - (**self).get_active_validator_set(block_height).await + async fn get_signing_validator_set(&self, consensus_height: u64) -> eyre::Result { + (**self).get_signing_validator_set(consensus_height).await } async fn get_consensus_params(&self, block_height: u64) -> eyre::Result { @@ -571,14 +629,14 @@ where async fn get_block_by_number( &self, - block_number: &str, + block_number: BlockNumberOrTag, ) -> eyre::Result> { (**self).get_block_by_number(block_number).await } async fn get_execution_payloads( &self, - block_numbers: &[String], + block_numbers: &[BlockNumberOrTag], ) -> eyre::Result>> { (**self).get_execution_payloads(block_numbers).await } @@ -602,8 +660,8 @@ impl EthereumAPI for Box { (**self).get_genesis_block().await } - async fn get_active_validator_set(&self, block_height: u64) -> eyre::Result { - (**self).get_active_validator_set(block_height).await + async fn get_signing_validator_set(&self, consensus_height: u64) -> eyre::Result { + (**self).get_signing_validator_set(consensus_height).await } async fn get_consensus_params(&self, block_height: u64) -> eyre::Result { @@ -612,14 +670,14 @@ impl EthereumAPI for Box { async fn get_block_by_number( &self, - block_number: &str, + block_number: BlockNumberOrTag, ) -> eyre::Result> { (**self).get_block_by_number(block_number).await } async fn get_execution_payloads( &self, - block_numbers: &[String], + block_numbers: &[BlockNumberOrTag], ) -> eyre::Result>> { (**self).get_execution_payloads(block_numbers).await } @@ -682,6 +740,118 @@ mod tests { assert!(now > 0); } + fn test_payload() -> ExecutionPayloadV3 { + use alloy_rpc_types_engine::{ExecutionPayloadV1, ExecutionPayloadV2}; + ExecutionPayloadV3 { + payload_inner: ExecutionPayloadV2 { + payload_inner: ExecutionPayloadV1 { + parent_hash: B256::ZERO, + fee_recipient: alloy_primitives::Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: alloy_primitives::Bloom::default(), + prev_randao: B256::ZERO, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: alloy_primitives::Bytes::default(), + base_fee_per_gas: alloy_primitives::U256::from(1u64), + block_hash: B256::ZERO, + transactions: vec![], + }, + withdrawals: vec![], + }, + blob_gas_used: 0, + excess_blob_gas: 0, + } + } + + fn parent_block() -> ExecutionBlock { + ExecutionBlock { + block_hash: B256::repeat_byte(0xAA), + block_number: 1, + parent_hash: B256::ZERO, + timestamp: 1000, + } + } + + /// Mock engine whose FCU/get_payload assert the timeout forwarded by + /// `generate_block` against `check`. + fn engine_checking_timeouts(check: fn(Duration, Duration)) -> Engine { + let parent_hash = parent_block().block_hash; + let mut api = MockEngineAPI::new(); + api.expect_forkchoice_updated() + .return_once(move |_, _, timeout| { + check(timeout, ENGINE_FORKCHOICE_UPDATED_TIMEOUT); + Ok(ForkchoiceUpdated::new(PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: Some(parent_hash), + }) + .with_payload_id(AlloyPayloadId::new([1; 8]))) + }); + api.expect_get_payload().return_once(move |_, _, timeout| { + check(timeout, ENGINE_GET_PAYLOAD_TIMEOUT); + Ok(test_payload()) + }); + Engine::new(Box::new(api), Box::new(MockEthereumAPI::new())) + } + + #[tokio::test] + async fn generate_block_without_deadline_uses_timeout_floors() { + let engine = engine_checking_timeouts(|timeout, floor| assert_eq!(timeout, floor)); + engine + .generate_block( + &parent_block(), + 1000, + &Address::from(alloy_primitives::Address::ZERO), + None, + ) + .await + .expect("generate_block should succeed"); + } + + #[tokio::test] + async fn generate_block_with_deadline_extends_timeout_floors() { + let engine = engine_checking_timeouts(|timeout, floor| assert!(timeout > floor)); + let deadline = EngineDeadline::within(Duration::from_secs(60)); + engine + .generate_block( + &parent_block(), + 1000, + &Address::from(alloy_primitives::Address::ZERO), + Some(deadline), + ) + .await + .expect("generate_block should succeed"); + } + + #[tokio::test] + async fn notify_new_block_resolves_timeout_from_deadline() { + let mut api = MockEngineAPI::new(); + api.expect_new_payload() + .times(2) + .returning(|_, _, _, timeout| { + assert!(timeout >= ENGINE_NEW_PAYLOAD_TIMEOUT); + Ok(PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: None, + }) + }); + let engine = Engine::new(Box::new(api), Box::new(MockEthereumAPI::new())); + + engine + .notify_new_block(&test_payload(), vec![], None) + .await + .expect("notify_new_block should succeed"); + + let deadline = EngineDeadline::within(Duration::from_secs(60)); + engine + .notify_new_block(&test_payload(), vec![], Some(deadline)) + .await + .expect("notify_new_block should succeed"); + } + #[rstest] #[case::osaka_at_zero( r#"{"config":{"chainId":1337,"osakaTime":0},"alloc":{}}"#, diff --git a/crates/eth-engine/src/ipc/engine_ipc.rs b/crates/eth-engine/src/ipc/engine_ipc.rs index 7a65c0db..437ef647 100644 --- a/crates/eth-engine/src/ipc/engine_ipc.rs +++ b/crates/eth-engine/src/ipc/engine_ipc.rs @@ -31,6 +31,7 @@ use crate::capabilities::EngineCapabilities; use crate::constants::*; use crate::engine::EngineAPI; use crate::ipc::ipc_builder::Ipc; +use crate::retry::NoRetry; /// Engine API client for connecting to Engine IPC via Unix Domain Socket. pub struct EngineIPC { @@ -100,6 +101,7 @@ impl EngineAPI for EngineIPC { &self, head_block_hash: BlockHash, maybe_payload_attributes: Option, + timeout: Duration, ) -> eyre::Result { let forkchoice_state = ForkchoiceState { head_block_hash, @@ -116,7 +118,7 @@ impl EngineAPI for EngineIPC { self.rpc_request( ENGINE_FORKCHOICE_UPDATED_V3, params, - ENGINE_FORKCHOICE_UPDATED_TIMEOUT, + timeout, ENGINE_API_RETRY_IPC.build(), ) .await @@ -128,7 +130,9 @@ impl EngineAPI for EngineIPC { &self, payload_id: AlloyPayloadId, use_v5: bool, + timeout: Duration, ) -> eyre::Result { + // NoRetry so transient errors surface identically on RPC and IPC. if use_v5 { let ExecutionPayloadEnvelopeV5 { execution_payload, .. @@ -136,8 +140,8 @@ impl EngineAPI for EngineIPC { .rpc_request( ENGINE_GET_PAYLOAD_V5, rpc_params![payload_id], - ENGINE_GET_PAYLOAD_TIMEOUT, - ENGINE_API_RETRY_IPC.build(), + timeout, + NoRetry, ) .await?; Ok(execution_payload) @@ -146,8 +150,8 @@ impl EngineAPI for EngineIPC { .rpc_request( ENGINE_GET_PAYLOAD_V4, rpc_params![payload_id], - ENGINE_GET_PAYLOAD_TIMEOUT, - ENGINE_API_RETRY_IPC.build(), + timeout, + NoRetry, ) .await?; Ok(envelope_inner.execution_payload) @@ -163,6 +167,7 @@ impl EngineAPI for EngineIPC { execution_payload: &ExecutionPayloadV3, versioned_hashes: Vec, parent_block_hash: BlockHash, + timeout: Duration, ) -> eyre::Result { let empty_execution_requests: Vec = Vec::new(); let params = rpc_params![ @@ -175,7 +180,7 @@ impl EngineAPI for EngineIPC { self.rpc_request( ENGINE_NEW_PAYLOAD_V4, params, - ENGINE_NEW_PAYLOAD_TIMEOUT, + timeout, ENGINE_API_RETRY_IPC.build(), ) .await diff --git a/crates/eth-engine/src/ipc/ethereum_ipc.rs b/crates/eth-engine/src/ipc/ethereum_ipc.rs index 4376d1cc..4b94f499 100644 --- a/crates/eth-engine/src/ipc/ethereum_ipc.rs +++ b/crates/eth-engine/src/ipc/ethereum_ipc.rs @@ -144,8 +144,15 @@ impl EthereumIPC { block.ok_or_else(|| eyre::eyre!("Genesis block not found")) } + /// Get the validator set that signs at the given consensus height. + /// Queries the registry at `consensus_height - 1` (saturating), because + /// the set active at height H is the one committed after executing H-1. #[tracing::instrument(skip(self))] - pub async fn get_active_validator_set(&self, block_height: u64) -> eyre::Result { + pub async fn get_signing_validator_set( + &self, + consensus_height: u64, + ) -> eyre::Result { + let block_height = consensus_height.saturating_sub(1); let params = abi_get_active_validator_set_params_ipc(block_height)?; debug!("eth_call params: {params:?}",); @@ -188,11 +195,10 @@ impl EthereumIPC { abi_decode_consensus_params(result) } - /// Get a block by its number. - /// - block_number: The number of the block to get. + /// Get a block by its number or tag. pub async fn get_block_by_number( &self, - block_number: &str, + block_number: BlockNumberOrTag, ) -> eyre::Result> { let return_full_transaction_objects = false; self.rpc_request( @@ -206,7 +212,7 @@ impl EthereumIPC { /// Get a batch of full execution payloads. async fn get_execution_payloads( &self, - block_numbers: &[String], + block_numbers: &[BlockNumberOrTag], ) -> eyre::Result>> { if block_numbers.is_empty() { return Ok(vec![]); @@ -216,7 +222,7 @@ impl EthereumIPC { let return_full_transaction_objects = true; let params_list = block_numbers .iter() - .map(|block_number| rpc_params![block_number, return_full_transaction_objects]) + .map(|block_number| rpc_params![*block_number, return_full_transaction_objects]) .collect::>(); let batch_blocks: Vec>> = self .ipc @@ -281,13 +287,12 @@ impl EthereumAPI for EthereumIPC { .wrap_err("EthereumIPC get_genesis_block call failed") } - /// Get the active validator set at a specific block height. - async fn get_active_validator_set(&self, block_height: u64) -> eyre::Result { - self.get_active_validator_set(block_height) + async fn get_signing_validator_set(&self, consensus_height: u64) -> eyre::Result { + self.get_signing_validator_set(consensus_height) .await .wrap_err_with(|| { format!( - "EthereumIPC get_active_validator_set call failed for height={block_height}" + "EthereumIPC get_signing_validator_set call failed for consensus_height={consensus_height}" ) }) } @@ -297,10 +302,10 @@ impl EthereumAPI for EthereumIPC { self.get_consensus_params(block_height).await } - /// Get a block by its number. + /// Get a block by its number or tag. async fn get_block_by_number( &self, - block_number: &str, + block_number: BlockNumberOrTag, ) -> eyre::Result> { self.get_block_by_number(block_number) .await @@ -314,7 +319,7 @@ impl EthereumAPI for EthereumIPC { /// Get a batch of full execution payloads. async fn get_execution_payloads( &self, - block_numbers: &[String], + block_numbers: &[BlockNumberOrTag], ) -> eyre::Result>> { self.get_execution_payloads(block_numbers) .await @@ -602,7 +607,7 @@ mod tests { let (socket_path, _handle) = start_mock_ipc_server(responses).await.unwrap(); let ethereum_ipc = EthereumIPC::new(&socket_path).await.unwrap(); - let block_numbers = vec!["0x1".to_string(), "0x2".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1), BlockNumberOrTag::Number(2)]; let result = ethereum_ipc .get_execution_payloads(&block_numbers) .await @@ -638,7 +643,7 @@ mod tests { let (socket_path, _handle) = start_mock_ipc_server(responses).await.unwrap(); let ethereum_ipc = EthereumIPC::new(&socket_path).await.unwrap(); - let block_numbers = vec!["0x1".to_string(), "0x999".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1), BlockNumberOrTag::Number(0x999)]; let result = ethereum_ipc .get_execution_payloads(&block_numbers) .await @@ -670,7 +675,7 @@ mod tests { let (socket_path, _handle) = start_mock_ipc_server(responses).await.unwrap(); let ethereum_ipc = EthereumIPC::new(&socket_path).await.unwrap(); - let block_numbers = vec!["0x1".to_string(), "0x2".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1), BlockNumberOrTag::Number(2)]; let result = ethereum_ipc .get_execution_payloads(&block_numbers) .await @@ -707,7 +712,7 @@ mod tests { let (socket_path, _handle) = start_mock_ipc_server(responses).await.unwrap(); let ethereum_ipc = EthereumIPC::new(&socket_path).await.unwrap(); - let block_numbers = vec!["0x1".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1)]; let result = ethereum_ipc .get_execution_payloads(&block_numbers) .await @@ -736,7 +741,7 @@ mod tests { let (socket_path, _handle) = start_mock_ipc_server(responses).await.unwrap(); let ethereum_ipc = EthereumIPC::new(&socket_path).await.unwrap(); - let block_numbers = vec!["0x1".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1)]; let result = ethereum_ipc .get_execution_payloads(&block_numbers) .await @@ -755,7 +760,7 @@ mod tests { let (socket_path, _handle) = start_mock_ipc_server(responses).await.unwrap(); let ethereum_ipc = EthereumIPC::new(&socket_path).await.unwrap(); - let block_numbers = vec!["0x1".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1)]; let result = ethereum_ipc .get_execution_payloads(&block_numbers) .await @@ -798,10 +803,10 @@ mod tests { let ethereum_ipc = EthereumIPC::new(&socket_path).await.unwrap(); let block_numbers = vec![ - "0x1".to_string(), - "0x2".to_string(), - "0x3".to_string(), - "0x4".to_string(), + BlockNumberOrTag::Number(1), + BlockNumberOrTag::Number(2), + BlockNumberOrTag::Number(3), + BlockNumberOrTag::Number(4), ]; let result = ethereum_ipc.get_execution_payloads(&block_numbers).await; diff --git a/crates/eth-engine/src/lib.rs b/crates/eth-engine/src/lib.rs index 9734b2b4..0b2f1dd6 100644 --- a/crates/eth-engine/src/lib.rs +++ b/crates/eth-engine/src/lib.rs @@ -15,6 +15,7 @@ // limitations under the License. pub mod capabilities; +pub mod deadline; pub mod engine; pub mod ipc; pub mod json_structures; @@ -25,7 +26,10 @@ pub mod rpc; mod abi_utils; mod constants; -pub use constants::INITIAL_RETRY_DELAY; +pub use constants::{ + ENGINE_FORKCHOICE_UPDATED_TIMEOUT, ENGINE_GET_PAYLOAD_TIMEOUT, ENGINE_NEW_PAYLOAD_TIMEOUT, + INITIAL_RETRY_DELAY, +}; #[cfg(any(test, feature = "mocks"))] pub mod mocks { diff --git a/crates/eth-engine/src/persistence_meter.rs b/crates/eth-engine/src/persistence_meter.rs index eb93cf33..c31d4908 100644 --- a/crates/eth-engine/src/persistence_meter.rs +++ b/crates/eth-engine/src/persistence_meter.rs @@ -19,6 +19,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use alloy_eips::BlockNumHash; +use alloy_rpc_types::BlockNumberOrTag; use async_trait::async_trait; use eyre::{eyre, Context}; use jsonrpsee::{ @@ -484,7 +485,7 @@ pub async fn create_with_fallback( /// startup before blocks accumulate, since `eth_getBlockByNumber("latest")` /// may return un-persisted heights during steady-state operation. pub async fn seed_from_latest_block(meter: &dyn PersistenceMeter, eth: &dyn EthereumAPI) { - match eth.get_block_by_number("latest").await { + match eth.get_block_by_number(BlockNumberOrTag::Latest).await { Ok(Some(block)) => { meter.seed(block.block_number); } diff --git a/crates/eth-engine/src/rpc/engine_rpc.rs b/crates/eth-engine/src/rpc/engine_rpc.rs index c4387a33..6559b6ab 100644 --- a/crates/eth-engine/src/rpc/engine_rpc.rs +++ b/crates/eth-engine/src/rpc/engine_rpc.rs @@ -113,6 +113,7 @@ impl EngineRpc { &self, head_block_hash: BlockHash, maybe_payload_attributes: Option, + timeout: Duration, ) -> eyre::Result { let forkchoice_state = ForkchoiceState { head_block_hash, @@ -123,7 +124,7 @@ impl EngineRpc { .rpc_request( ENGINE_FORKCHOICE_UPDATED_V3, json!([forkchoice_state, maybe_payload_attributes]), - ENGINE_FORKCHOICE_UPDATED_TIMEOUT, + timeout, NoRetry, ) .await; @@ -143,27 +144,19 @@ impl EngineRpc { &self, payload_id: AlloyPayloadId, use_v5: bool, + timeout: Duration, ) -> eyre::Result { + // NoRetry so transient errors surface identically on RPC and IPC. let execution_payload = if use_v5 { let ExecutionPayloadEnvelopeV5 { execution_payload, .. } = self - .rpc_request( - ENGINE_GET_PAYLOAD_V5, - json!([payload_id]), - ENGINE_GET_PAYLOAD_TIMEOUT, - NoRetry, - ) + .rpc_request(ENGINE_GET_PAYLOAD_V5, json!([payload_id]), timeout, NoRetry) .await?; execution_payload } else { let ExecutionPayloadEnvelopeV4 { envelope_inner, .. } = self - .rpc_request( - ENGINE_GET_PAYLOAD_V4, - json!([payload_id]), - ENGINE_GET_PAYLOAD_TIMEOUT, - NoRetry, - ) + .rpc_request(ENGINE_GET_PAYLOAD_V4, json!([payload_id]), timeout, NoRetry) .await?; envelope_inner.execution_payload }; @@ -183,6 +176,7 @@ impl EngineRpc { execution_payload: &ExecutionPayloadV3, versioned_hashes: Vec, parent_block_hash: BlockHash, + timeout: Duration, ) -> eyre::Result { let empty_execution_requests: Vec = Vec::new(); let params = json!([ @@ -192,12 +186,7 @@ impl EngineRpc { empty_execution_requests ]); let res = self - .rpc_request( - ENGINE_NEW_PAYLOAD_V4, - params, - ENGINE_NEW_PAYLOAD_TIMEOUT, - NoRetry, - ) + .rpc_request(ENGINE_NEW_PAYLOAD_V4, params, timeout, NoRetry) .await; let block_hash = execution_payload.payload_inner.payload_inner.block_hash; @@ -221,16 +210,21 @@ impl EngineAPI for EngineRpc { &self, head_block_hash: BlockHash, maybe_payload_attributes: Option, + timeout: Duration, ) -> eyre::Result { - EngineRpc::forkchoice_updated(self, head_block_hash, maybe_payload_attributes.clone()) - .await - .wrap_err_with(|| { - format!( - "EngineRpc forkchoice_updated call failed; block_hash {:?}, payload_attributes {:?}", - head_block_hash, - maybe_payload_attributes, - ) - }) + EngineRpc::forkchoice_updated( + self, + head_block_hash, + maybe_payload_attributes.clone(), + timeout, + ) + .await + .wrap_err_with(|| { + format!( + "EngineRpc forkchoice_updated call failed; block_hash {:?}, payload_attributes {:?}", + head_block_hash, maybe_payload_attributes, + ) + }) } /// Get a payload by its ID. @@ -238,8 +232,9 @@ impl EngineAPI for EngineRpc { &self, payload_id: AlloyPayloadId, use_v5: bool, + timeout: Duration, ) -> eyre::Result { - EngineRpc::get_payload(self, payload_id, use_v5) + EngineRpc::get_payload(self, payload_id, use_v5, timeout) .await .wrap_err_with(|| format!("EngineRpc get_payload call failed; payload_id {payload_id}")) } @@ -250,10 +245,17 @@ impl EngineAPI for EngineRpc { execution_payload: &ExecutionPayloadV3, versioned_hashes: Vec, parent_block_hash: BlockHash, + timeout: Duration, ) -> eyre::Result { let payload_hash = execution_payload.payload_inner.payload_inner.block_hash; - EngineRpc::new_payload(self, execution_payload, versioned_hashes, parent_block_hash) - .await - .wrap_err_with(|| format!("EngineRpc new_payload call failed; block_hash {payload_hash}, parent_block_hash {parent_block_hash}")) + EngineRpc::new_payload( + self, + execution_payload, + versioned_hashes, + parent_block_hash, + timeout, + ) + .await + .wrap_err_with(|| format!("EngineRpc new_payload call failed; block_hash {payload_hash}, parent_block_hash {parent_block_hash}")) } } diff --git a/crates/eth-engine/src/rpc/errors.rs b/crates/eth-engine/src/rpc/errors.rs index 553b6d54..1336b8d1 100644 --- a/crates/eth-engine/src/rpc/errors.rs +++ b/crates/eth-engine/src/rpc/errors.rs @@ -18,7 +18,7 @@ use std::fmt; use crate::rpc::json_structs::JsonError; use eyre::Report; -use jsonrpsee_types::error::ErrorObject; +use jsonrpsee_types::error::{ErrorObject, INTERNAL_ERROR_CODE}; use thiserror::Error; /// Error codes taken from reth's code. @@ -57,6 +57,7 @@ impl EngineApiRpcError { /// Classifies the error into a specific kind. pub fn kind(&self) -> EngineRpcErrorKind { match self.code { + INTERNAL_ERROR_CODE => EngineRpcErrorKind::Internal, UNKNOWN_PAYLOAD_CODE => EngineRpcErrorKind::UnknownPayload, UNSUPPORTED_FORK_CODE => EngineRpcErrorKind::UnsupportedFork, _ => EngineRpcErrorKind::Other, @@ -72,6 +73,11 @@ impl EngineApiRpcError { pub fn is_unsupported_fork(&self) -> bool { self.kind() == EngineRpcErrorKind::UnsupportedFork } + + /// Checks if the engine returned no payload verdict because of an internal error. + pub fn is_internal_error(&self) -> bool { + self.kind() == EngineRpcErrorKind::Internal + } } impl fmt::Display for EngineApiRpcError { @@ -130,6 +136,8 @@ impl TryFrom for EngineApiRpcError { /// Can easily be extended with additional kinds. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EngineRpcErrorKind { + /// The engine could not process the request and returned no payload verdict. + Internal, /// reth thinks that the payload does not exist or is not available locally. UnknownPayload, /// reth does not support the requested Engine API version for the current fork. @@ -161,10 +169,13 @@ mod tests { let unknown = EngineApiRpcError::new(UNKNOWN_PAYLOAD_CODE, "Unknown payload", None); assert_eq!(unknown.kind(), EngineRpcErrorKind::UnknownPayload); assert!(unknown.is_unknown_payload()); + } - let other = EngineApiRpcError::new(-32603, "Internal error", None); - assert_eq!(other.kind(), EngineRpcErrorKind::Other); - assert!(!other.is_unknown_payload()); + #[test] + fn internal_error() { + let internal = EngineApiRpcError::new(-32603, "Internal error", None); + assert_eq!(internal.kind(), EngineRpcErrorKind::Internal); + assert!(internal.is_internal_error()); } #[test] @@ -195,6 +206,19 @@ mod tests { assert_eq!(err.kind(), EngineRpcErrorKind::Other); } + #[test] + fn from_json_internal_error() { + let json_err = JsonError { + code: -32603, + message: "Internal error".to_string(), + data: Some(json!("engine unavailable")), + }; + + let err = EngineApiRpcError::from(json_err); + assert_eq!(err.kind(), EngineRpcErrorKind::Internal); + assert!(err.is_internal_error()); + } + #[test] fn from_error_object() { use jsonrpsee_types::error::ErrorObjectOwned; @@ -205,4 +229,15 @@ mod tests { assert_eq!(err.to_string(), "Code 42: some error: \"root cause\""); assert_eq!(err.kind(), EngineRpcErrorKind::Other); } + + #[test] + fn from_internal_error_object() { + use jsonrpsee_types::error::ErrorObjectOwned; + + let owned = ErrorObjectOwned::owned(-32603, "Internal error", Some("engine unavailable")); + let err = EngineApiRpcError::from(owned); + + assert_eq!(err.kind(), EngineRpcErrorKind::Internal); + assert!(err.is_internal_error()); + } } diff --git a/crates/eth-engine/src/rpc/ethereum_rpc.rs b/crates/eth-engine/src/rpc/ethereum_rpc.rs index f0a5d166..c24de5fa 100644 --- a/crates/eth-engine/src/rpc/ethereum_rpc.rs +++ b/crates/eth-engine/src/rpc/ethereum_rpc.rs @@ -17,7 +17,7 @@ use std::time::Duration; use alloy_consensus::TxEnvelope; -use alloy_rpc_types::Block; +use alloy_rpc_types::{Block, BlockNumberOrTag}; use alloy_rpc_types_engine::ExecutionPayloadV3; use alloy_rpc_types_txpool::{TxpoolInspect, TxpoolStatus}; use alloy_sol_types::SolCall; @@ -150,8 +150,15 @@ impl EthereumRPC { block.ok_or_else(|| eyre::eyre!("Genesis block not found")) } + /// Get the validator set that signs at the given consensus height. + /// Queries the registry at `consensus_height - 1` (saturating), because + /// the set active at height H is the one committed after executing H-1. #[tracing::instrument(skip(self))] - pub async fn get_active_validator_set(&self, block_height: u64) -> eyre::Result { + pub async fn get_signing_validator_set( + &self, + consensus_height: u64, + ) -> eyre::Result { + let block_height = consensus_height.saturating_sub(1); let params = abi_get_active_validator_set_params_rpc(block_height)?; debug!("eth_call params: {params}"); @@ -164,7 +171,7 @@ impl EthereumRPC { .await .wrap_err_with(|| { format!( - "eth_call request for active validator set failed for height={block_height}" + "eth_call request for signing validator set failed for consensus_height={consensus_height}" ) })?; @@ -196,11 +203,10 @@ impl EthereumRPC { abi_decode_consensus_params(result) } - /// Get a block by its number. - /// - block_number: The number of the block to get. + /// Get a block by its number or tag. pub async fn get_block_by_number( &self, - block_number: &str, + block_number: BlockNumberOrTag, ) -> eyre::Result> { let return_full_transaction_objects = false; let params = json!([block_number, return_full_transaction_objects]); @@ -211,7 +217,7 @@ impl EthereumRPC { /// Get a batch of full execution payloads. async fn get_execution_payloads( &self, - block_numbers: &[String], + block_numbers: &[BlockNumberOrTag], ) -> eyre::Result>> { if block_numbers.is_empty() { return Ok(vec![]); @@ -338,13 +344,12 @@ impl EthereumAPI for EthereumRPC { .wrap_err("EthereumRPC get_genesis_block call failed") } - /// Get the active validator set at a specific block height. - async fn get_active_validator_set(&self, block_height: u64) -> eyre::Result { - self.get_active_validator_set(block_height) + async fn get_signing_validator_set(&self, consensus_height: u64) -> eyre::Result { + self.get_signing_validator_set(consensus_height) .await .wrap_err_with(|| { format!( - "EthereumRPC get_active_validator_set call failed for height={block_height}" + "EthereumRPC get_signing_validator_set call failed for consensus_height={consensus_height}" ) }) } @@ -354,10 +359,10 @@ impl EthereumAPI for EthereumRPC { self.get_consensus_params(block_height).await } - /// Get a block by its number. + /// Get a block by its number or tag. async fn get_block_by_number( &self, - block_number: &str, + block_number: BlockNumberOrTag, ) -> eyre::Result> { self.get_block_by_number(block_number) .await @@ -371,7 +376,7 @@ impl EthereumAPI for EthereumRPC { /// Get a batch of full execution payloads. async fn get_execution_payloads( &self, - block_numbers: &[String], + block_numbers: &[BlockNumberOrTag], ) -> eyre::Result>> { self.get_execution_payloads(block_numbers) .await @@ -570,7 +575,7 @@ mod tests { let url = Url::parse(&server.uri()).unwrap(); let ethereum_rpc = EthereumRPC::new(url).unwrap(); - let block_numbers = vec!["0x1".to_string(), "0x2".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1), BlockNumberOrTag::Number(2)]; let result = ethereum_rpc .get_execution_payloads(&block_numbers) .await @@ -635,7 +640,7 @@ mod tests { let url = Url::parse(&server.uri()).unwrap(); let ethereum_rpc = EthereumRPC::new(url).unwrap(); - let block_numbers = vec!["0x1".to_string(), "0x999".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1), BlockNumberOrTag::Number(0x999)]; let result = ethereum_rpc .get_execution_payloads(&block_numbers) .await @@ -682,7 +687,7 @@ mod tests { let url = Url::parse(&server.uri()).unwrap(); let ethereum_rpc = EthereumRPC::new(url).unwrap(); - let block_numbers = vec!["0x1".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1)]; let result = ethereum_rpc .get_execution_payloads(&block_numbers) .await @@ -725,7 +730,7 @@ mod tests { let url = Url::parse(&server.uri()).unwrap(); let ethereum_rpc = EthereumRPC::new(url).unwrap(); - let block_numbers = vec!["0x1".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1)]; let result = ethereum_rpc .get_execution_payloads(&block_numbers) .await @@ -741,7 +746,7 @@ mod tests { let url = Url::parse("http://invalid-host:8545").unwrap(); let ethereum_rpc = EthereumRPC::new(url).unwrap(); - let block_numbers = vec!["0x1".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1)]; let result = ethereum_rpc.get_execution_payloads(&block_numbers).await; assert!(result.is_err()); @@ -784,7 +789,7 @@ mod tests { let url = Url::parse(&server.uri()).unwrap(); let ethereum_rpc = EthereumRPC::new(url).unwrap(); - let block_numbers = vec!["0x1".to_string()]; + let block_numbers = vec![BlockNumberOrTag::Number(1)]; let result = ethereum_rpc .get_execution_payloads(&block_numbers) .await diff --git a/crates/eth-engine/tests/integration.rs b/crates/eth-engine/tests/integration.rs index 3ae93fe5..3922ab91 100644 --- a/crates/eth-engine/tests/integration.rs +++ b/crates/eth-engine/tests/integration.rs @@ -32,20 +32,24 @@ use alloy_rpc_types_engine::JwtSecret; use reth_node_builder::{NodeBuilder, NodeConfig}; use reth_tasks::TaskExecutor; +use alloy_rpc_types::BlockNumberOrTag; +use arc_consensus_types::block::canonical_block_hash; use arc_consensus_types::Address; use arc_eth_engine::ipc::engine_ipc::EngineIPC; use arc_eth_engine::retry::NoRetry; use arc_eth_engine::rpc::EngineApiRpcError; use arc_eth_engine::{engine::Engine, rpc::engine_rpc::EngineRpc}; use arc_evm_node::node::{ArcNode, ArcRpcConfig}; -use arc_evm_node::ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT; -use arc_execution_config::addresses_denylist::AddressesDenylistConfig; +use arc_evm_node::{ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, DEFAULT_TX_RELAY_TIMEOUT}; +use arc_execution_config::addresses_denylist::{ + AddressesDenylistConfig, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, DENYLIST_ADDRESS_LOCALDEV, +}; use arc_execution_config::chainspec::ArcChainSpec; use arc_execution_config::chainspec::LOCAL_DEV; use arc_execution_txpool::InvalidTxListConfig; /// Common test suite for engine implementations -async fn test_engine_common(engine: &Engine, initial_block_number: &str) { +async fn test_engine_common(engine: &Engine, initial_block_number: BlockNumberOrTag) { // Test getting chain ID let chain_id = engine .eth @@ -73,12 +77,12 @@ async fn test_engine_common(engine: &Engine, initial_block_number: &str) { .expect("Failed to get block"); assert!(block.is_some(), "Block should exist"); - // Test get active validator set + // Test get signing validator set (queries contract at consensus_height - 1 = 0) let validator_set = engine .eth - .get_active_validator_set(0) + .get_signing_validator_set(1) .await - .expect("Failed to get active validator set"); + .expect("Failed to get signing validator set"); assert!( !validator_set.validators.is_empty(), "Validator set should not be empty" @@ -106,14 +110,26 @@ async fn test_engine_common(engine: &Engine, initial_block_number: &str) { // Test generate new block let fee_recipient = Address::repeat_byte(0xBE); let block = engine - .generate_block(&block.unwrap(), Engine::timestamp_now() + 1, &fee_recipient) + .generate_block( + &block.unwrap(), + Engine::timestamp_now() + 1, + &fee_recipient, + None, + ) .await .expect("Failed to generate new block"); assert!(block.payload_inner.payload_inner.block_hash.len() == 32); + // Guard against canonical_block_hash drifting from the EL's block hash. + assert_eq!( + canonical_block_hash(&block).expect("recompute canonical block hash"), + block.payload_inner.payload_inner.block_hash, + "canonical_block_hash drifted from the execution layer's block hash", + ); + // Test notify new block let status = engine - .notify_new_block(&block, Vec::new()) + .notify_new_block(&block, Vec::new(), None) .await .expect("Failed to notify new block"); assert!( @@ -156,7 +172,11 @@ async fn test_engine() { let arc_node = ArcNode::new( ArcRpcConfig::default(), InvalidTxListConfig::default(), - AddressesDenylistConfig::default(), + AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + ), None, true, true, @@ -164,6 +184,8 @@ async fn test_engine() { 160 * 1024 * 1024, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, std::time::Duration::from_secs(0), // disable rebroadcast in integration tests + Vec::new(), + DEFAULT_TX_RELAY_TIMEOUT, ); let node_handle = NodeBuilder::new(node_config) .testing_node(executor) @@ -209,7 +231,7 @@ async fn test_engine() { assert!(txpool_inspect.queued.is_empty()); // Run common engine tests - test_engine_common(&engine, "0x0").await; + test_engine_common(&engine, BlockNumberOrTag::Number(0)).await; // Test IPC error handling let ipc_client = @@ -247,7 +269,7 @@ async fn test_engine() { engine.set_osaka_from_chain_id(1337); // Run common engine tests - test_engine_common(&engine, "latest").await; + test_engine_common(&engine, BlockNumberOrTag::Latest).await; // Test RPC error handling let rpc_client = EngineRpc::new( diff --git a/crates/evm-node/Cargo.toml b/crates/evm-node/Cargo.toml index becb2d73..e78900dd 100644 --- a/crates/evm-node/Cargo.toml +++ b/crates/evm-node/Cargo.toml @@ -15,10 +15,13 @@ integration = ["arc-evm/integration"] [dependencies] alloy-consensus = { workspace = true, features = ["serde"] } +alloy-eips.workspace = true alloy-network.workspace = true alloy-primitives.workspace = true +alloy-rpc-client.workspace = true alloy-rpc-types-engine.workspace = true alloy-rpc-types-eth.workspace = true +alloy-transport.workspace = true arc-consensus-types.workspace = true arc-evm.workspace = true arc-execution-config.workspace = true @@ -30,6 +33,7 @@ async-trait.workspace = true backon.workspace = true eyre.workspace = true jsonrpsee.workspace = true +metrics.workspace = true reqwest.workspace = true reth-chainspec.workspace = true reth-engine-primitives.workspace = true @@ -62,7 +66,12 @@ tower.workspace = true tracing.workspace = true [dev-dependencies] +alloy-rlp.workspace = true +alloy-rpc-types-engine = { workspace = true, features = ["ssz"] } arc-execution-config = { path = "../execution-config", features = ["test-utils"] } +bytesize.workspace = true +ethereum_ssz.workspace = true +reth-consensus-common.workspace = true revm-primitives.workspace = true [lints] diff --git a/crates/evm-node/src/engine.rs b/crates/evm-node/src/engine.rs index 16be2c0a..83b49432 100644 --- a/crates/evm-node/src/engine.rs +++ b/crates/evm-node/src/engine.rs @@ -84,6 +84,16 @@ where )); } + if payload + .payload + .withdrawals() + .is_some_and(|withdrawals| !withdrawals.is_empty()) + { + return Err(NewPayloadError::Other( + "Withdrawals are not supported".into(), + )); + } + self.inner .ensure_well_formed_payload(payload) .map_err(Into::into) @@ -152,14 +162,21 @@ where mod tests { use super::*; use alloy_primitives::{hex, Address, Bytes, B256, U256}; + use alloy_rlp::Encodable; use alloy_rpc_types_engine::{ CancunPayloadFields, ExecutionPayload, ExecutionPayloadSidecar, ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3, PraguePayloadFields, }; + use bytesize::ByteSize; use reth_chainspec::ChainSpecBuilder; + use reth_consensus_common::validation::MAX_RLP_BLOCK_SIZE; use reth_ethereum::primitives::Header; use reth_ethereum_engine_primitives::{EthEngineTypes, EthPayloadTypes}; use revm_primitives::hex::FromHex; + use ssz::Encode; + + /// Mirrors `value_sync::MAX_RESPONSE_SIZE` in `crates/malachite-app/src/hardcoded_config.rs`. + const VALUE_SYNC_MAX_RESPONSE_SIZE: ByteSize = ByteSize::mib(10); fn create_validator() -> ArcEngineValidator { let chain_spec = Arc::new(ChainSpecBuilder::mainnet().build()); @@ -192,6 +209,19 @@ mod tests { } } + fn execution_data_v3(payload: ExecutionPayloadV3) -> ExecutionData { + ExecutionData { + payload: ExecutionPayload::V3(payload), + sidecar: ExecutionPayloadSidecar::v4( + CancunPayloadFields::new( + hex!("a5ddd3f286f429458a39cafc13ffe89295a7efa8eb363cf89a1a4887dbcf272b").into(), + vec![], + ), + PraguePayloadFields::default(), + ), + } + } + #[test] fn validate_payload_attributes_against_header_valid() { let validator = create_validator(); @@ -208,6 +238,7 @@ mod tests { suggested_fee_recipient: Address::ZERO, withdrawals: None, parent_beacon_block_root: None, + slot_number: None, }; assert!( as PayloadValidator>::validate_payload_attributes_against_header( @@ -223,6 +254,7 @@ mod tests { suggested_fee_recipient: Address::ZERO, withdrawals: None, parent_beacon_block_root: None, + slot_number: None, }; assert!( as PayloadValidator>::validate_payload_attributes_against_header( @@ -247,6 +279,7 @@ mod tests { suggested_fee_recipient: Address::ZERO, withdrawals: None, parent_beacon_block_root: None, + slot_number: None, }; assert!(matches!( as PayloadValidator>::validate_payload_attributes_against_header( @@ -319,4 +352,52 @@ mod tests { _ => panic!("Unexpected result: {:?}", result), } } + + #[test] + fn ensure_well_formed_payload_rejects_non_empty_withdrawals() { + let validator = create_validator(); + let mut new_payload = create_test_payload_v3(0); + new_payload.payload_inner.withdrawals = vec![Default::default()]; + + let result = + >::ensure_well_formed_payload( + &validator, + execution_data_v3(new_payload), + ); + + match result { + Err(NewPayloadError::Other(msg)) => { + assert_eq!(msg.to_string(), "Withdrawals are not supported"); + } + _ => panic!("Unexpected result: {:?}", result), + } + } + + #[test] + fn ensure_well_formed_payload_rejects_withdrawals_payload_over_value_sync_limit() { + const WITHDRAWALS_COUNT: usize = 238_500; + + let validator = create_validator(); + let mut new_payload = create_test_payload_v3(0); + new_payload.payload_inner.withdrawals = vec![Default::default(); WITHDRAWALS_COUNT]; + + let rlp_len = new_payload.clone().into_block_raw().unwrap().length(); + let ssz_len = new_payload.ssz_bytes_len(); + + assert!(rlp_len <= MAX_RLP_BLOCK_SIZE); + assert!(ByteSize::b(ssz_len as u64) > VALUE_SYNC_MAX_RESPONSE_SIZE); + + let result = + >::ensure_well_formed_payload( + &validator, + execution_data_v3(new_payload), + ); + + match result { + Err(NewPayloadError::Other(msg)) => { + assert_eq!(msg.to_string(), "Withdrawals are not supported"); + } + _ => panic!("Unexpected result: {:?}", result), + } + } } diff --git a/crates/evm-node/src/lib.rs b/crates/evm-node/src/lib.rs index 8a90742a..85fba9eb 100644 --- a/crates/evm-node/src/lib.rs +++ b/crates/evm-node/src/lib.rs @@ -28,4 +28,6 @@ pub mod rpc_middleware; // Re-export commonly used types pub use engine::ArcEngineValidator; -pub use rpc_middleware::{ArcRpcLayer, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT}; +pub use rpc_middleware::{ + ArcRpcLayer, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, DEFAULT_TX_RELAY_TIMEOUT, +}; diff --git a/crates/evm-node/src/node.rs b/crates/evm-node/src/node.rs index 8bb65fc9..b8ce9f4d 100644 --- a/crates/evm-node/src/node.rs +++ b/crates/evm-node/src/node.rs @@ -29,9 +29,7 @@ use arc_execution_validation::ArcConsensus; use reth_chainspec::{EthereumHardforks, Hardforks}; use reth_engine_primitives::EngineTypes; use reth_ethereum::{node::EthEngineTypes, node::EthEvmConfig}; -use reth_ethereum_engine_primitives::{ - EthBuiltPayload, EthPayloadAttributes, EthPayloadBuilderAttributes, -}; +use reth_ethereum_engine_primitives::{EthBuiltPayload, EthPayloadAttributes}; use reth_ethereum_primitives::EthPrimitives; use reth_evm::{ConfigureEvm, EvmFactory, EvmFactoryFor, NextBlockEnvAttributes}; use reth_network::{primitives::BasicNetworkPrimitives, NetworkHandle, PeersInfo}; @@ -59,7 +57,9 @@ use reth_rpc::{ ValidationApi, }; use reth_rpc_api::servers::BlockSubmissionValidationApiServer; -use reth_rpc_builder::{config::RethRpcServerConfig, middleware::RethRpcMiddleware}; +use reth_rpc_builder::{ + config::RethRpcServerConfig, middleware::RethRpcMiddleware, TransportRpcModules, +}; use reth_rpc_eth_api::{ helpers::{ config::{EthConfigApiServer, EthConfigHandler}, @@ -82,9 +82,26 @@ use arc_execution_txpool::{ArcPoolBuilder, InvalidTxList, InvalidTxListConfig}; // FIXME use the ethereum chain spec temporary, we need to define Arc chain spec // original traits for ChainSpec in this file `Hardforks + EthereumHardforks + EthExecutorSpec` -use crate::rpc_middleware::{ArcRpcLayer, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT}; +use crate::rpc_middleware::{ + ArcRpcLayer, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, DEFAULT_TX_RELAY_TIMEOUT, +}; use crate::ArcEngineValidator; +/// Bundle RPC methods that Arc never exposes on public RPC transports. +const BUNDLE_RPC_METHODS: [&str; 6] = [ + "eth_callBundle", + "eth_sendBundle", + "eth_cancelBundle", + "eth_sendPrivateTransaction", + "eth_sendPrivateRawTransaction", + "eth_cancelPrivateTransaction", +]; + +fn remove_public_bundle_rpc_methods(modules: &mut TransportRpcModules) { + modules.remove_http_methods(BUNDLE_RPC_METHODS); + modules.remove_ws_methods(BUNDLE_RPC_METHODS); +} + /// Type configuration for a regular Arc node. #[derive(Debug, Clone)] #[non_exhaustive] @@ -115,14 +132,19 @@ pub struct ArcNode { pub max_batch_entries: usize, /// Interval between tx rebroadcast rounds. Zero disables rebroadcast. pub rebroadcast_interval: std::time::Duration, + /// Upstream RPC URLs for transaction relay, in priority order. Empty disables relaying. + pub tx_relays: Vec, + /// Connection timeout for relays, and request timeout for async relayed submissions. + pub tx_relay_timeout: std::time::Duration, } -impl Default for ArcNode { - fn default() -> Self { +impl ArcNode { + /// Node with default settings and an explicitly supplied denylist config. + pub fn with_denylist_config(addresses_denylist_config: AddressesDenylistConfig) -> Self { Self { rpc_cfg: ArcRpcConfig::default(), invalid_tx_list_cfg: InvalidTxListConfig::default(), - addresses_denylist_config: AddressesDenylistConfig::default(), + addresses_denylist_config, payload_builder_deadline_ms: None, wait_for_payload: true, filter_pending_txs: true, @@ -130,11 +152,11 @@ impl Default for ArcNode { max_response_body_size: 160 * 1024 * 1024, max_batch_entries: ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, rebroadcast_interval: crate::rebroadcast::DEFAULT_REBROADCAST_INTERVAL, + tx_relays: Vec::new(), + tx_relay_timeout: DEFAULT_TX_RELAY_TIMEOUT, } } -} -impl ArcNode { /// Creates a new `ArcNode`. #[allow(clippy::too_many_arguments)] pub fn new( @@ -148,6 +170,8 @@ impl ArcNode { max_response_body_size: u32, max_batch_entries: usize, rebroadcast_interval: std::time::Duration, + tx_relays: Vec, + tx_relay_timeout: std::time::Duration, ) -> Self { Self { rpc_cfg, @@ -160,6 +184,8 @@ impl ArcNode { max_response_body_size, max_batch_entries, rebroadcast_interval, + tx_relays, + tx_relay_timeout, } } @@ -180,11 +206,8 @@ impl ArcNode { > where Node: FullNodeTypes>, - ::Payload: PayloadTypes< - BuiltPayload = EthBuiltPayload, - PayloadAttributes = EthPayloadAttributes, - PayloadBuilderAttributes = EthPayloadBuilderAttributes, - >, + ::Payload: + PayloadTypes, { let invalid_tx_list_opt = if invalid_tx_list_cfg.enabled { info!( @@ -327,6 +350,7 @@ where BasicEngineApiBuilder::default(), BasicEngineValidatorBuilder::default(), Default::default(), + Default::default(), ); Self::new(addons, ArcRpcConfig::default()) } @@ -415,7 +439,7 @@ where Arc::new(ctx.node.consensus().clone()), ctx.node.evm_config().clone(), ctx.config.rpc.flashbots_config(), - Box::new(ctx.node.task_executor().clone()), + ctx.node.task_executor().clone(), Arc::new(ArcEngineValidator::new(ctx.config.chain.clone())), ); @@ -432,6 +456,9 @@ where container .modules .merge_if_module_configured(RethRpcModule::Eth, eth_config.into_rpc())?; + // Reth includes eth_callBundle in the eth namespace. Remove the full bundle API + // from externally reachable transports while retaining trusted local IPC access. + remove_public_bundle_rpc_methods(container.modules); if self.arc_rpc.enabled { if let Ok(arc_module) = @@ -537,6 +564,8 @@ where self.allow_unprotected_txs, self.max_response_body_size as usize, self.max_batch_entries, + self.tx_relays.clone(), + self.tx_relay_timeout, )) } } @@ -689,6 +718,72 @@ where #[cfg(test)] mod tests { use super::*; + use arc_execution_config::addresses_denylist::{ + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, DENYLIST_ADDRESS_LOCALDEV, + }; + use jsonrpsee::RpcModule; + + fn module_with_bundle_methods() -> RpcModule<()> { + let mut module = RpcModule::new(()); + for method in BUNDLE_RPC_METHODS { + module.register_method(method, |_, _, _| "bundle").unwrap(); + } + module + .register_method("eth_blockNumber", |_, _, _| "0x1") + .unwrap(); + module + } + + #[test] + fn removes_bundle_rpc_methods_from_public_transports_only() { + let module = module_with_bundle_methods(); + let mut modules = TransportRpcModules::default() + .with_http(module.clone()) + .with_ws(module.clone()) + .with_ipc(module); + + remove_public_bundle_rpc_methods(&mut modules); + + for method in BUNDLE_RPC_METHODS { + assert!(modules + .http_methods(|name| name == method) + .unwrap() + .method_names() + .next() + .is_none()); + assert!(modules + .ws_methods(|name| name == method) + .unwrap() + .method_names() + .next() + .is_none()); + assert!(modules + .ipc_methods(|name| name == method) + .unwrap() + .method_names() + .next() + .is_some()); + } + + assert!(modules + .http_methods(|name| name == "eth_blockNumber") + .unwrap() + .method_names() + .next() + .is_some()); + assert!(modules + .ws_methods(|name| name == "eth_blockNumber") + .unwrap() + .method_names() + .next() + .is_some()); + assert!(modules + .ipc_methods(|name| name == "eth_blockNumber") + .unwrap() + .method_names() + .next() + .is_some()); + } #[test] fn arc_rpc_config_construction() { @@ -722,7 +817,11 @@ mod tests { let node = ArcNode::new( rpc_cfg, invalid_tx_list_cfg, - AddressesDenylistConfig::default(), + AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + ), None, true, true, @@ -730,26 +829,27 @@ mod tests { 160 * 1024 * 1024, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, crate::rebroadcast::DEFAULT_REBROADCAST_INTERVAL, + Vec::new(), + DEFAULT_TX_RELAY_TIMEOUT, ); assert!(!node.rpc_cfg.enabled); assert!(node.invalid_tx_list_cfg.enabled); assert_eq!(node.invalid_tx_list_cfg.capacity, 25_000); - assert!(!node.addresses_denylist_config.is_enabled()); + assert_eq!( + node.addresses_denylist_config.contract_address(), + DENYLIST_ADDRESS_LOCALDEV + ); } #[test] fn arc_node_construction_with_addresses_denylist_config() { use alloy_primitives::{address, b256}; - let addresses_cfg = AddressesDenylistConfig::try_new( - true, - Some(address!("0x3600000000000000000000000000000000000001")), - Some(b256!( - "0x0000000000000000000000000000000000000000000000000000000000000001" - )), + let addresses_cfg = AddressesDenylistConfig::new( + address!("0x3600000000000000000000000000000000000001"), + b256!("0x0000000000000000000000000000000000000000000000000000000000000001"), Vec::new(), - ) - .unwrap(); + ); let node = ArcNode::new( ArcRpcConfig::default(), InvalidTxListConfig::default(), @@ -761,24 +861,22 @@ mod tests { 160 * 1024 * 1024, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, crate::rebroadcast::DEFAULT_REBROADCAST_INTERVAL, + Vec::new(), + DEFAULT_TX_RELAY_TIMEOUT, + ); + assert_eq!( + node.addresses_denylist_config.contract_address(), + address!("0x3600000000000000000000000000000000000001") ); - assert!(node.addresses_denylist_config.is_enabled()); - if let AddressesDenylistConfig::Enabled { - contract_address, .. - } = &node.addresses_denylist_config - { - assert_eq!( - *contract_address, - address!("0x3600000000000000000000000000000000000001") - ); - } else { - panic!("expected Enabled variant"); - } } #[test] fn arc_node_default_has_pending_txs_filter_enabled() { - let node = ArcNode::default(); + let node = ArcNode::with_denylist_config(AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + )); assert!( node.filter_pending_txs, "Default ArcNode should have pending txs filter enabled" @@ -790,7 +888,11 @@ mod tests { let node = ArcNode::new( ArcRpcConfig::default(), InvalidTxListConfig::default(), - AddressesDenylistConfig::default(), + AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + ), None, true, false, @@ -798,13 +900,19 @@ mod tests { 160 * 1024 * 1024, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, crate::rebroadcast::DEFAULT_REBROADCAST_INTERVAL, + Vec::new(), + DEFAULT_TX_RELAY_TIMEOUT, ); assert!(!node.filter_pending_txs); } #[test] fn arc_node_default_wait_for_payload_enabled() { - let node = ArcNode::default(); + let node = ArcNode::with_denylist_config(AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + )); assert!( node.wait_for_payload, "Default ArcNode should have wait_for_payload enabled" @@ -816,7 +924,11 @@ mod tests { let node = ArcNode::new( ArcRpcConfig::default(), InvalidTxListConfig::default(), - AddressesDenylistConfig::default(), + AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + ), None, false, false, @@ -824,13 +936,19 @@ mod tests { 160 * 1024 * 1024, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, crate::rebroadcast::DEFAULT_REBROADCAST_INTERVAL, + Vec::new(), + DEFAULT_TX_RELAY_TIMEOUT, ); assert!(!node.wait_for_payload); } #[test] fn arc_node_default_rebroadcast_interval() { - let node = ArcNode::default(); + let node = ArcNode::with_denylist_config(AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + )); assert_eq!( node.rebroadcast_interval, crate::rebroadcast::DEFAULT_REBROADCAST_INTERVAL @@ -842,7 +960,11 @@ mod tests { let node = ArcNode::new( ArcRpcConfig::default(), InvalidTxListConfig::default(), - AddressesDenylistConfig::default(), + AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + ), None, true, true, @@ -850,6 +972,8 @@ mod tests { 160 * 1024 * 1024, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, std::time::Duration::ZERO, + Vec::new(), + DEFAULT_TX_RELAY_TIMEOUT, ); assert!(node.rebroadcast_interval.is_zero()); } diff --git a/crates/evm-node/src/payload.rs b/crates/evm-node/src/payload.rs index a31b66f1..a03e4cf0 100644 --- a/crates/evm-node/src/payload.rs +++ b/crates/evm-node/src/payload.rs @@ -59,6 +59,7 @@ where ); EthPayloadAttributes { + slot_number: None, timestamp, prev_randao: B256::random(), // Mock CL uses genesis coinbase as suggested fee recipient diff --git a/crates/evm-node/src/rpc/arc.rs b/crates/evm-node/src/rpc/arc.rs index 7991fa8a..1b291473 100644 --- a/crates/evm-node/src/rpc/arc.rs +++ b/crates/evm-node/src/rpc/arc.rs @@ -16,7 +16,7 @@ //! Top level file of `reth` node API extension RPCs -use crate::rpc::common::ARC_DEFAULT_BASE_URL; +use crate::rpc::common::{HexOrDecimalU64, ARC_DEFAULT_BASE_URL}; use crate::rpc::get_certificate::{ rpc_get_certificate, CertificateSource, HttpCertificateSource, RpcCommitCertificate, }; @@ -35,7 +35,7 @@ pub trait ArcApi { fn version(&self) -> RpcResult; #[method(name = "getCertificate")] - async fn get_certificate(&self, height: u64) -> RpcResult; + async fn get_certificate(&self, height: HexOrDecimalU64) -> RpcResult; } pub struct ArcApiImpl { @@ -50,8 +50,8 @@ impl ArcApiImpl { #[async_trait] impl ArcApiServer for ArcApiImpl { - async fn get_certificate(&self, height: u64) -> RpcResult { - rpc_get_certificate(&self.certificate_source, height).await + async fn get_certificate(&self, height: HexOrDecimalU64) -> RpcResult { + rpc_get_certificate(&self.certificate_source, height.as_u64()).await } fn version(&self) -> RpcResult { rpc_get_version() diff --git a/crates/evm-node/src/rpc/common.rs b/crates/evm-node/src/rpc/common.rs index a6e88279..3f013260 100644 --- a/crates/evm-node/src/rpc/common.rs +++ b/crates/evm-node/src/rpc/common.rs @@ -15,9 +15,58 @@ // limitations under the License. use jsonrpsee::types::{ErrorCode, ErrorObjectOwned}; +use serde::de; +use std::fmt; pub const ARC_DEFAULT_BASE_URL: &str = "http://127.0.0.1:31000"; +/// A u64 that deserializes from either a JSON number or a hex quantity string (`"0x…"`). +/// Ethereum proxies (e.g. eRPC) rewrite numeric params to hex; this accepts both. +#[derive(Debug, Clone, Copy)] +pub struct HexOrDecimalU64(u64); + +impl HexOrDecimalU64 { + pub fn as_u64(self) -> u64 { + self.0 + } +} + +impl From for HexOrDecimalU64 { + fn from(v: u64) -> Self { + Self(v) + } +} + +impl<'de> de::Deserialize<'de> for HexOrDecimalU64 { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(HexOrDecimalU64Visitor) + } +} + +struct HexOrDecimalU64Visitor; + +impl<'de> de::Visitor<'de> for HexOrDecimalU64Visitor { + type Value = HexOrDecimalU64; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a u64 or hex-encoded quantity string") + } + + fn visit_u64(self, v: u64) -> Result { + Ok(HexOrDecimalU64(v)) + } + + fn visit_str(self, v: &str) -> Result { + let hex = v + .strip_prefix("0x") + .or_else(|| v.strip_prefix("0X")) + .ok_or_else(|| de::Error::custom("hex string must start with 0x"))?; + u64::from_str_radix(hex, 16) + .map(HexOrDecimalU64) + .map_err(de::Error::custom) + } +} + pub fn invalid_params(msg: impl Into) -> ErrorObjectOwned { ErrorObjectOwned::owned(ErrorCode::InvalidParams.code(), msg.into(), None::<()>) } @@ -28,3 +77,43 @@ pub mod codes { /// Upstream service unreachable (TCP connect failures). pub const UPSTREAM_UNREACHABLE: i32 = -32005; } + +#[cfg(test)] +mod tests { + use super::*; + + fn deser(json: &str) -> Result { + serde_json::from_str(json) + } + + #[test] + fn accepts_decimal_number() { + assert_eq!(deser("123").unwrap().as_u64(), 123); + } + + #[test] + fn accepts_hex_string() { + assert_eq!(deser(r#""0x7b""#).unwrap().as_u64(), 123); + } + + #[test] + fn accepts_hex_uppercase_prefix() { + assert_eq!(deser(r#""0X7B""#).unwrap().as_u64(), 123); + } + + #[test] + fn accepts_zero() { + assert_eq!(deser("0").unwrap().as_u64(), 0); + assert_eq!(deser(r#""0x0""#).unwrap().as_u64(), 0); + } + + #[test] + fn rejects_non_hex_string() { + assert!(deser(r#""abc""#).is_err()); + } + + #[test] + fn rejects_empty_hex() { + assert!(deser(r#""0x""#).is_err()); + } +} diff --git a/crates/evm-node/src/rpc/get_certificate.rs b/crates/evm-node/src/rpc/get_certificate.rs index 901f5362..6eb36ed1 100644 --- a/crates/evm-node/src/rpc/get_certificate.rs +++ b/crates/evm-node/src/rpc/get_certificate.rs @@ -197,7 +197,9 @@ mod tests { #[tokio::test] async fn rejects_invalid_height() { let api = ArcApiImpl::new(MockCertSource); - let err = ArcApiServer::get_certificate(&api, 0).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 0.into()) + .await + .unwrap_err(); assert!(err.message().contains("height must be")); } @@ -211,7 +213,9 @@ mod tests { } } let api = ArcApiImpl::new(EmptySource); - let err = ArcApiServer::get_certificate(&api, 5).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 5.into()) + .await + .unwrap_err(); assert_eq!(err.code(), NOT_FOUND); } struct ConnectFail; @@ -225,7 +229,9 @@ mod tests { #[tokio::test] async fn connect_failure_maps_to_custom_code() { let api = ArcApiImpl::new(ConnectFail); - let err = ArcApiServer::get_certificate(&api, 10).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 10.into()) + .await + .unwrap_err(); assert_eq!(err.code(), UPSTREAM_UNREACHABLE); assert!(err.message().contains("unreachable")); } @@ -233,7 +239,7 @@ mod tests { #[tokio::test] async fn returns_real_cert_from_source() { let api = ArcApiImpl::new(MockCertSource); - let cert = ArcApiServer::get_certificate(&api, 7).await.unwrap(); + let cert = ArcApiServer::get_certificate(&api, 7.into()).await.unwrap(); assert_eq!(cert.height, 7); assert_eq!(cert.round, 3); } @@ -248,7 +254,9 @@ mod tests { #[tokio::test] async fn timeout_maps_to_internal_error() { let api = ArcApiImpl::new(TimeoutSource); - let err = ArcApiServer::get_certificate(&api, 9).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 9.into()) + .await + .unwrap_err(); assert_eq!(err.code(), ErrorCode::InternalError.code()); assert!(err.message().contains("Timeout")); } @@ -264,7 +272,9 @@ mod tests { #[tokio::test] async fn decode_error_maps_to_internal_error() { let api = ArcApiImpl::new(DecodeFailSource); - let err = ArcApiServer::get_certificate(&api, 11).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 11.into()) + .await + .unwrap_err(); assert_eq!(err.code(), ErrorCode::InternalError.code()); assert!(err.message().contains("Failed to decode")); } @@ -280,7 +290,9 @@ mod tests { #[tokio::test] async fn network_status_maps_to_internal_error() { let api = ArcApiImpl::new(NetworkFailSource); - let err = ArcApiServer::get_certificate(&api, 13).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 13.into()) + .await + .unwrap_err(); assert_eq!(err.code(), ErrorCode::InternalError.code()); assert!(err.message().contains("Upstream HTTP error")); assert!(err.message().contains("500")); diff --git a/crates/evm-node/src/rpc/openapi/openapi.yaml b/crates/evm-node/src/rpc/openapi/openapi.yaml index 411cc774..8fb35c36 100644 --- a/crates/evm-node/src/rpc/openapi/openapi.yaml +++ b/crates/evm-node/src/rpc/openapi/openapi.yaml @@ -40,17 +40,21 @@ paths: description: | Single JSON-RPC 2.0 POST endpoint. Methods currently implemented under the `arc` namespace: - 1. arc_getCertificate(height: u64) -> RpcCommitCertificate + 1. arc_getCertificate(height: u64 | hex string) -> RpcCommitCertificate Returns the consensus commit certificate for the specified height if available. + The height parameter accepts either a decimal number (7) or a hex quantity string ("0x7"). Error Mapping (error.code -> meaning): -32602 Invalid params (height < 1) -32004 Not found (no certificate upstream) -32005 Upstream unreachable (connect errors) -32603 Internal (timeout / decode / generic upstream HTTP status) - Curl Example: + Curl Examples: curl -s -X POST -H 'Content-Type: application/json' \ --data '{"jsonrpc":"2.0","method":"arc_getCertificate","params":[7],"id":1}' \ http://127.0.0.1:8545 + curl -s -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"arc_getCertificate","params":["0x7"],"id":1}' \ + http://127.0.0.1:8545 2. arc_getVersion() -> RpcVersionInfo Returns version/build information for the node execution layer. @@ -108,9 +112,13 @@ components: minItems: 1 maxItems: 1 items: - type: integer - minimum: 1 - format: int64 + oneOf: + - type: integer + minimum: 1 + format: int64 + - type: string + pattern: "^0[xX][0-9a-fA-F]+$" + description: Hex-encoded quantity (e.g. "0x7"). Must be >= 1. id: oneOf: - type: integer diff --git a/crates/evm-node/src/rpc_middleware.rs b/crates/evm-node/src/rpc_middleware.rs index 0491e3b4..aa01c635 100644 --- a/crates/evm-node/src/rpc_middleware.rs +++ b/crates/evm-node/src/rpc_middleware.rs @@ -15,22 +15,52 @@ // limitations under the License. use alloy_consensus::TxEnvelope; +use alloy_eips::{BlockId, BlockNumberOrTag}; use alloy_network::eip2718::Decodable2718; use alloy_primitives::Bytes; +use alloy_rpc_client::RpcClient; +use alloy_rpc_types_eth::pubsub::SubscriptionKind; +use alloy_transport::{RpcError, TransportErrorKind}; use jsonrpsee::{ core::middleware::{layer::Either, Batch, BatchEntry, Notification, RpcServiceT}, - types::{ErrorObject, ErrorObjectOwned, Id, Request, ResponsePayload}, + types::{ + error::ErrorCode, ErrorObject, ErrorObjectOwned, Id, Params, Request, ResponsePayload, + }, BatchResponseBuilder, MethodResponse, }; -use std::future::Future; +use serde::de::DeserializeOwned; +use std::{ + future::Future, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; use tower::Layer; const ETH_SUBSCRIBE_METHOD: &str = "eth_subscribe"; -const PENDING_TX_SUBSCRIPTION_TYPE: &str = "newPendingTransactions"; const ETH_NEW_PENDING_TX_FILTER_METHOD: &str = "eth_newPendingTransactionFilter"; const ETH_GET_BLOCK_BY_NUMBER_METHOD: &str = "eth_getBlockByNumber"; -const PENDING_BLOCK_TAG: &str = "pending"; +const BLOCK_NUMBER_OBJECT_KEY: &str = "number"; +const BLOCK_ID_OBJECT_KEY_SNAKE: &str = "block_id"; +const BLOCK_ID_OBJECT_KEY_CAMEL: &str = "blockId"; const ETH_GET_TX_BY_SENDER_AND_NONCE_METHOD: &str = "eth_getTransactionBySenderAndNonce"; +const ETH_GET_BLOCK_RECEIPTS_METHOD: &str = "eth_getBlockReceipts"; +const ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD: &str = "eth_getBlockTransactionCountByNumber"; +const ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD: &str = "eth_getTransactionByBlockNumberAndIndex"; +const ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD: &str = + "eth_getRawTransactionByBlockNumberAndIndex"; +const ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD: &str = "eth_getUncleCountByBlockNumber"; +const ETH_GET_HEADER_BY_NUMBER_METHOD: &str = "eth_getHeaderByNumber"; +// jsonrpsee proc-macro field name for eth_getHeaderByNumber's BlockNumberOrTag param. +// Reth's trait declares `hash: BlockNumberOrTag` (copy-paste from getHeaderByHash) — +// see reth-rpc-eth-api/src/core.rs. If that arg is ever renamed, update this key and +// the object-form test cases; the array/positional form is unaffected. +const BLOCK_HEADER_NUMBER_OBJECT_KEY: &str = "hash"; +// jsonrpsee proc-macro field name for eth_subscribe's SubscriptionKind param. +// Reth's trait declares `kind: SubscriptionKind` — see reth-rpc-eth-api/src/pubsub.rs. +const SUBSCRIPTION_KIND_OBJECT_KEY: &str = "kind"; const ETH_SEND_RAW_TRANSACTION_METHOD: &str = "eth_sendRawTransaction"; const ETH_SEND_RAW_TRANSACTION_SYNC_METHOD: &str = "eth_sendRawTransactionSync"; const PENDING_TX_SUBSCRIPTION_ERROR_CODE: i32 = -32001; @@ -38,16 +68,25 @@ const BATCH_TOO_LARGE_ERROR_CODE: i32 = -32600; const UNPROTECTED_TX_ERROR_CODE: i32 = -32000; const UNPROTECTED_TX_ERROR_MSG: &str = "only replay-protected (EIP-155) transactions allowed over RPC"; +const RELAY_UNAVAILABLE_ERROR_CODE: i32 = -32010; +const RELAY_UNAVAILABLE_ERROR_MSG: &str = "all transaction relay upstreams are unreachable"; /// Default maximum number of entries permitted in a JSON-RPC batch request. pub const ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT: usize = 100; +/// Default transaction relay timeout. +pub const DEFAULT_TX_RELAY_TIMEOUT: Duration = Duration::from_secs(10); + /// Config for the Arc-specific RPC middleware stack. #[derive(Clone, Debug)] pub struct ArcRpcLayer { - /// When true (default), `eth_subscribe("newPendingTransactions")`, - /// `eth_newPendingTransactionFilter`, `eth_getBlockByNumber("pending")`, - /// and `eth_getTransactionBySenderAndNonce` are blocked. + /// When true (default), blocks all RPC methods that can expose pending-block + /// state: `eth_subscribe("newPendingTransactions")`, `eth_newPendingTransactionFilter`, + /// `eth_getTransactionBySenderAndNonce`, and the block-content methods + /// `eth_getBlockByNumber`, `eth_getBlockReceipts`, + /// `eth_getBlockTransactionCountByNumber`, `eth_getTransactionByBlockNumberAndIndex`, + /// `eth_getRawTransactionByBlockNumberAndIndex`, `eth_getUncleCountByBlockNumber`, + /// and `eth_getHeaderByNumber` when called with the `"pending"` tag. /// When false, the filter is bypassed and these are allowed. /// CLI users opt out of the default via `--arc.expose-pending-txs`. pub filter_pending_txs: bool, @@ -60,6 +99,8 @@ pub struct ArcRpcLayer { pub max_response_body_size: usize, /// Maximum number of entries permitted in a JSON-RPC batch request. pub max_batch_entries: usize, + /// Transaction relay upstreams, or `None` when relaying is disabled. + pub tx_relays: Option, } impl Default for ArcRpcLayer { @@ -69,6 +110,7 @@ impl Default for ArcRpcLayer { allow_unprotected_txs: false, max_response_body_size: usize::MAX, max_batch_entries: ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, + tx_relays: None, } } } @@ -79,12 +121,15 @@ impl ArcRpcLayer { allow_unprotected_txs: bool, max_response_body_size: usize, max_batch_entries: usize, + tx_relays: Vec, + tx_relay_timeout: Duration, ) -> Self { Self { filter_pending_txs, allow_unprotected_txs, max_response_body_size, max_batch_entries, + tx_relays: TxRelays::new(&tx_relays, max_response_body_size, tx_relay_timeout), } } } @@ -96,17 +141,29 @@ where S: Clone, { type Service = BatchSizeLimitMiddleware< - RejectUnprotectedTxsMiddleware, S>>, + RejectUnprotectedTxsMiddleware< + Either< + NoPendingTransactionsRpcMiddleware, S>>, + Either, S>, + >, + >, >; fn layer(&self, inner: S) -> Self::Service { + let relayed = match &self.tx_relays { + Some(relays) => Either::Left(TxRelayMiddleware { + service: inner, + relays: relays.clone(), + }), + None => Either::Right(inner), + }; let pending_layer = if self.filter_pending_txs { Either::Left(NoPendingTransactionsRpcMiddleware { - service: inner, + service: relayed, max_response_body_size: self.max_response_body_size, }) } else { - Either::Right(inner) + Either::Right(relayed) }; let service = RejectUnprotectedTxsMiddleware { service: pending_layer, @@ -384,21 +441,8 @@ fn error_if_unprotected_send_raw_tx<'a>(req: &Request<'a>) -> Result<(), ErrorOb if !is_raw_transaction_submission(req.method_name()) { return Ok(()); } - #[derive(serde::Deserialize)] - struct SendRawTransactionParams { - bytes: Bytes, - } - - let bytes = if req.params().is_object() { - let Ok(params) = req.params().parse::() else { - return Ok(()); - }; - params.bytes - } else { - let Ok((bytes,)) = req.params().parse::<(Bytes,)>() else { - return Ok(()); - }; - bytes + let Some(bytes) = extract_raw_tx_bytes(req) else { + return Ok(()); }; let Ok(envelope) = TxEnvelope::decode_2718_exact(bytes.as_ref()) else { return Ok(()); @@ -417,6 +461,290 @@ fn is_raw_transaction_submission(method: &str) -> bool { method == ETH_SEND_RAW_TRANSACTION_METHOD || method == ETH_SEND_RAW_TRANSACTION_SYNC_METHOD } +/// Extracts the raw transaction bytes from a raw-tx submission request. +/// +/// Handles both positional (`[bytes]`) and object (`{"bytes": ...}`) params; +/// returns `None` on any other shape or a parse failure. +fn extract_raw_tx_bytes(req: &Request<'_>) -> Option { + #[derive(serde::Deserialize)] + struct SendRawTransactionParams { + bytes: Bytes, + } + if req.params().is_object() { + req.params() + .parse::() + .ok() + .map(|p| p.bytes) + } else { + req.params().parse::<(Bytes,)>().ok().map(|(bytes,)| bytes) + } +} + +/// Per-upstream relay clients plus the sticky-selection cursor. +/// +/// Cheap to clone: clients and the sticky index are shared via `Arc`, so every +/// RPC transport that clones the layer observes the same failover state. +#[derive(Clone, Debug)] +pub struct TxRelays { + clients: Arc<[RpcClient]>, + sticky: Arc, + max_response_body_size: usize, + request_timeout: Duration, +} + +/// Result of forwarding a raw-tx submission across the upstream list. +enum RelayOutcome { + /// An upstream accepted the tx; carries the JSON result to return. + Ok(serde_json::Value), + /// A reachable upstream returned a JSON-RPC (or non-failover) error; return verbatim. + UpstreamError(ErrorObjectOwned), + /// Every upstream failed a full pass. + AllUnreachable, +} + +impl TxRelays { + /// Builds relay state from priority-ordered upstream URLs, or `None` when + /// none are valid (relaying disabled). URLs are pre-validated at the CLI + /// boundary; any that fail to parse here are skipped. + /// + /// `timeout` is applied as the connection timeout to every upstream and, in + /// [`Self::forward`], as the request timeout for async submissions. + pub fn new(urls: &[String], max_response_body_size: usize, timeout: Duration) -> Option { + let http = reqwest::Client::builder() + .connect_timeout(timeout) + .build() + .inspect_err(|e| { + tracing::warn!(target: "rpc::relay", %e, "failed to build tx relay HTTP client; relaying disabled"); + }) + .ok()?; + let clients: Vec = urls + .iter() + .filter_map(|u| match reqwest::Url::parse(u) { + Ok(url) => Some(RpcClient::new_http_with_client(http.clone(), url)), + Err(e) => { + tracing::warn!(target: "rpc::relay", url = %u, %e, "skipping invalid tx relay URL"); + None + } + }) + .collect(); + (!clients.is_empty()).then(|| Self { + clients: clients.into(), + sticky: Arc::new(AtomicUsize::new(0)), + max_response_body_size, + request_timeout: timeout, + }) + } + + /// Forwards a raw-tx submission with sticky last-good, cyclic advance: + /// start at the sticky upstream, advance past any that fail with a transport + /// error, timeout, or HTTP 5xx/429, wrapping once through the list before + /// giving up. + async fn forward(&self, method: &str, bytes: &Bytes) -> RelayOutcome { + let n = self.clients.len(); + let start = self.sticky.load(Ordering::Relaxed).min(n.saturating_sub(1)); + for (idx, client) in self.clients.iter().enumerate().cycle().skip(start).take(n) { + let call = client.request::<_, serde_json::Value>(method.to_string(), (bytes.clone(),)); + let result = match tokio::time::timeout(self.request_timeout, call).await { + Ok(result) => result, + Err(_elapsed) => { + metrics::counter!("arc_tx_relay_failovers_total").increment(1); + tracing::debug!(target: "rpc::relay", idx, "tx relay upstream timed out; advancing"); + continue; + } + }; + match result { + Ok(result) => { + self.sticky.store(idx, Ordering::Relaxed); + return RelayOutcome::Ok(result); + } + Err(RpcError::ErrorResp(payload)) => { + self.sticky.store(idx, Ordering::Relaxed); + let code = i32::try_from(payload.code) + .unwrap_or_else(|_| ErrorCode::InternalError.code()); + return RelayOutcome::UpstreamError(ErrorObjectOwned::owned( + code, + payload.message.into_owned(), + payload.data, + )); + } + Err(err) if should_failover(&err) => { + metrics::counter!("arc_tx_relay_failovers_total").increment(1); + tracing::debug!(target: "rpc::relay", idx, error = %relay_error_category(&err), "tx relay upstream failed; advancing"); + } + Err(err) => { + self.sticky.store(idx, Ordering::Relaxed); + // Return a generic message so a public RPC client cannot + // learn upstream host details; log only the error category. + tracing::warn!(target: "rpc::relay", idx, error = %relay_error_category(&err), "tx relay upstream returned a non-retryable error"); + return RelayOutcome::UpstreamError(ErrorObjectOwned::owned::<()>( + ErrorCode::InternalError.code(), + "transaction relay upstream error", + None, + )); + } + } + } + metrics::counter!("arc_tx_relay_exhausted_total").increment(1); + RelayOutcome::AllUnreachable + } +} + +/// Returns true when the error means the upstream is unusable and the relay +/// should advance: any transport failure, or an HTTP 5xx / 429 status. +fn should_failover(err: &RpcError) -> bool { + match err { + RpcError::Transport(TransportErrorKind::HttpError(http)) => { + http.status == 429 || http.status >= 500 + } + RpcError::ErrorResp(_) => false, + _ => true, + } +} + +/// Coarse, URL-free category for a relay error, safe to log. +/// +/// `RpcError`'s `Display` embeds the upstream URL (via reqwest), which can +/// carry API keys in its path or query; this exposes only the failure shape. +fn relay_error_category(err: &RpcError) -> String { + match err { + RpcError::Transport(TransportErrorKind::HttpError(http)) => { + format!("http status {}", http.status) + } + RpcError::Transport(_) => "transport error".to_string(), + RpcError::ErrorResp(_) => "json-rpc error response".to_string(), + _ => "rpc error".to_string(), + } +} + +/// RPC middleware that relays raw-transaction submission to a prioritized list +/// of upstreams with failover, retaining each accepted tx in the local pool. +#[derive(Clone, Debug)] +pub struct TxRelayMiddleware { + service: S, + relays: TxRelays, +} + +impl TxRelayMiddleware +where + S: RpcServiceT + Send + Sync, +{ + /// Relays a raw-tx submission, then best-effort retains it in the local pool. + /// Requests with unparseable params are forwarded to the local handler so it + /// can return its own precise error. + async fn relay(&self, req: Request<'_>) -> MethodResponse { + let Some(bytes) = extract_raw_tx_bytes(&req) else { + return self.service.call(req).await; + }; + let id = req.id().into_owned(); + let method = req.method_name().to_string(); + match self.relays.forward(&method, &bytes).await { + RelayOutcome::Ok(result) => { + self.local_add(&bytes).await; + MethodResponse::response( + id, + ResponsePayload::success(result).into(), + self.relays.max_response_body_size, + ) + } + RelayOutcome::UpstreamError(err) => MethodResponse::error(id, err), + RelayOutcome::AllUnreachable => MethodResponse::error( + id, + ErrorObjectOwned::owned::<()>( + RELAY_UNAVAILABLE_ERROR_CODE, + RELAY_UNAVAILABLE_ERROR_MSG, + None, + ), + ), + } + } + + /// Retains an accepted tx in the local pool via an async + /// `eth_sendRawTransaction` to the inner service. Best-effort: the result, + /// including any error, is intentionally ignored. + async fn local_add(&self, bytes: &Bytes) { + let Ok(params) = serde_json::value::to_raw_value(&(bytes,)) else { + return; + }; + let req = Request::owned( + ETH_SEND_RAW_TRANSACTION_METHOD.to_string(), + Some(params), + Id::Null, + ); + let _ = self.service.call(req).await; + } +} + +impl RpcServiceT for TxRelayMiddleware +where + S: RpcServiceT< + MethodResponse = MethodResponse, + NotificationResponse = MethodResponse, + BatchResponse = MethodResponse, + > + Send + + Sync + + Clone + + 'static, +{ + type MethodResponse = S::MethodResponse; + type NotificationResponse = S::NotificationResponse; + type BatchResponse = S::BatchResponse; + + fn call<'a>(&self, req: Request<'a>) -> impl Future + Send + 'a { + let this = self.clone(); + async move { + if is_raw_transaction_submission(req.method_name()) { + this.relay(req).await + } else { + this.service.call(req).await + } + } + } + + fn batch<'a>(&self, req: Batch<'a>) -> impl Future + Send + 'a { + let this = self.clone(); + let max_response_body_size = self.relays.max_response_body_size; + async move { + let mut builder = BatchResponseBuilder::new_with_limit(max_response_body_size); + let mut got_notif = false; + for entry in req { + match entry { + Ok(BatchEntry::Call(request)) => { + let response = if is_raw_transaction_submission(request.method_name()) { + this.relay(request).await + } else { + this.service.call(request).await + }; + if let Err(too_large) = builder.append(response) { + return too_large; + } + } + Ok(BatchEntry::Notification(notification)) => { + got_notif = true; + this.service.notification(notification).await; + } + Err(err) => { + let (error, id) = err.into_parts(); + if let Err(too_large) = builder.append(MethodResponse::error(id, error)) { + return too_large; + } + } + } + } + if builder.is_empty() && got_notif { + return MethodResponse::notification(); + } + MethodResponse::from_batch(builder.finish()) + } + } + + fn notification<'a>( + &self, + n: Notification<'a>, + ) -> impl Future + Send + 'a { + self.service.notification(n) + } +} + /// Returns an error if the request is a pending-tx RPC (subscription or filter) that would leak pending transaction data. fn error_if_pending_tx_rpc<'a>(req: &Request<'a>) -> Result<(), ErrorObject<'a>> { if req.method_name() == ETH_NEW_PENDING_TX_FILTER_METHOD { @@ -428,18 +756,17 @@ fn error_if_pending_tx_rpc<'a>(req: &Request<'a>) -> Result<(), ErrorObject<'a>> return Err(error); } - if req.method_name() == ETH_SUBSCRIBE_METHOD { - // Parse parameters to check if it's for newPendingTransactions - if let Ok(Some(subscription_type)) = req.params().sequence().optional_next::() { - if subscription_type == PENDING_TX_SUBSCRIPTION_TYPE { - let error = ErrorObjectOwned::owned::<()>( - PENDING_TX_SUBSCRIPTION_ERROR_CODE, - "Subscriptions to pending transactions are not allowed", - None, - ); - return Err(error); - } - } + // Extract the binding's own type so every encoding it accepts is checked. + if req.method_name() == ETH_SUBSCRIBE_METHOD + && extract_param::(req.params(), &[SUBSCRIPTION_KIND_OBJECT_KEY]) + .is_some_and(|kind| kind == SubscriptionKind::NewPendingTransactions) + { + let error = ErrorObjectOwned::owned::<()>( + PENDING_TX_SUBSCRIPTION_ERROR_CODE, + "Subscriptions to pending transactions are not allowed", + None, + ); + return Err(error); } Ok(()) } @@ -449,19 +776,59 @@ fn is_pool_pending_tx_lookup(req: &Request<'_>) -> bool { req.method_name() == ETH_GET_TX_BY_SENDER_AND_NONCE_METHOD } -/// Returns true if the request is `eth_getBlockByNumber("pending", ...)`. +fn is_pending_block_method(method: &str) -> bool { + method == ETH_GET_BLOCK_BY_NUMBER_METHOD + || method == ETH_GET_BLOCK_RECEIPTS_METHOD + || method == ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD + || method == ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD + || method == ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD + || method == ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD + || method == ETH_GET_HEADER_BY_NUMBER_METHOD +} + +/// Returns true if the request queries pending-block state via a block number/tag parameter. /// /// The consensus engine may briefly expose a pending block via `provider().pending_block()` /// even when `--rpc.pending-block=none` is set. Intercepting at the middleware layer /// guarantees a consistent `null` response regardless of consensus-engine state. fn is_pending_block_query(req: &Request<'_>) -> bool { - if req.method_name() != ETH_GET_BLOCK_BY_NUMBER_METHOD { + if !is_pending_block_method(req.method_name()) { return false; } - if let Ok(Some(block_tag)) = req.params().sequence().optional_next::() { - return block_tag == PENDING_BLOCK_TAG; + let params = req.params(); + // eth_getBlockReceipts accepts BlockId: handles string tags and EIP-1898 object form + // ({"blockNumber": "pending"}). All other methods accept BlockNumberOrTag. + if req.method_name() == ETH_GET_BLOCK_RECEIPTS_METHOD { + return extract_param::( + params, + &[BLOCK_ID_OBJECT_KEY_SNAKE, BLOCK_ID_OBJECT_KEY_CAMEL], + ) + .is_some_and(|id| id.is_pending()); + } + // Object key for named params — coupled to jsonrpsee proc-macro field names. + let key = if req.method_name() == ETH_GET_HEADER_BY_NUMBER_METHOD { + BLOCK_HEADER_NUMBER_OBJECT_KEY + } else { + BLOCK_NUMBER_OBJECT_KEY + }; + extract_param::(params, &[key]).is_some_and(|t| t.is_pending()) +} + +/// Extracts and deserializes the first positional or named RPC param. +/// +/// For object-style params, tries each key in `keys` in order. For array-style +/// params, reads the first element. Returns `None` on missing field or parse failure. +/// +/// `BlockNumberOrTag` deserialization lowercases tags internally, so case variants +/// ("Pending", "PENDING") deserialize correctly without extra normalization. +fn extract_param(params: Params<'_>, keys: &[&str]) -> Option { + if params.is_object() { + let obj = params.parse::().ok()?; + let val = keys.iter().find_map(|k| obj.get(*k))?.clone(); + serde_json::from_value(val).ok() + } else { + params.sequence().optional_next::().ok().flatten() } - false } /// Builds a JSON-RPC success response containing `null`. @@ -473,6 +840,7 @@ fn null_response(req: &Request<'_>) -> MethodResponse { #[cfg(test)] mod tests { use super::*; + use alloy_transport::mock::Asserter; use jsonrpsee::{ types::{Id, ResponsePayload}, BatchResponseBuilder, @@ -589,6 +957,49 @@ mod tests { ); } + // jsonrpsee accepts object params for subscriptions too, keyed by the trait's + // arg name (`kind`), with the optional second arg omitted or explicitly null. + #[tokio::test] + async fn test_enabled_blocks_pending_tx_subscription_object_params() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[&str] = &[ + r#"{"kind":"newPendingTransactions"}"#, + r#"{"kind":"newPendingTransactions","params":null}"#, + ]; + for params_json in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(ETH_SUBSCRIBE_METHOD, params, 1); + let response = middleware.call(request).await; + + assert_eq!( + response.as_error_code(), + Some(PENDING_TX_SUBSCRIPTION_ERROR_CODE), + "filter_pending_txs=true should block {params_json}" + ); + } + } + + // `{"newPendingTransactions":null}` parses to the same kind as the bare string. + #[tokio::test] + async fn test_enabled_blocks_pending_tx_subscription_map_form_kind() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[&str] = &[ + r#"[{"newPendingTransactions":null}]"#, + r#"{"kind":{"newPendingTransactions":null}}"#, + ]; + for params_json in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(ETH_SUBSCRIBE_METHOD, params, 1); + let response = middleware.call(request).await; + + assert_eq!( + response.as_error_code(), + Some(PENDING_TX_SUBSCRIPTION_ERROR_CODE), + "filter_pending_txs=true should block {params_json}" + ); + } + } + #[tokio::test] async fn test_enabled_blocks_pending_tx_filter() { let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); @@ -618,6 +1029,15 @@ mod tests { (r#"["NewPendingTransactions"]"#, "wrong-case pendingTx"), ("[]", "empty params"), ("[123]", "non-string params"), + (r#"{"kind":"newHeads"}"#, "object newHeads"), + (r#"{"kind":"logs"}"#, "object logs"), + (r#"{"kind":"syncing"}"#, "object syncing"), + ( + r#"{"kind":"NewPendingTransactions"}"#, + "object wrong-case pendingTx", + ), + ("{}", "object missing kind"), + (r#"{"kind":123}"#, "object non-string kind"), ]; for (params_json, label) in cases { let params = RawValue::from_string(params_json.to_string()).unwrap(); @@ -859,6 +1279,32 @@ mod tests { ); } + #[tokio::test] + async fn test_enabled_batch_new_method_pending_returns_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let batch = Batch::from(vec![ + Ok(BatchEntry::Call(create_request_with_params( + "eth_blockNumber", + RawValue::from_string("[]".to_string()).unwrap(), + 1, + ))), + Ok(BatchEntry::Call(create_request_with_params( + ETH_GET_BLOCK_RECEIPTS_METHOD, + RawValue::from_string(r#"["pending"]"#.to_string()).unwrap(), + 2, + ))), + ]); + let response = middleware.batch(batch).await; + let json = response.into_json(); + let responses: Vec = serde_json::from_str(json.get()).unwrap(); + assert_eq!(responses.len(), 2); + assert_eq!(responses[0]["result"], "success"); + assert!( + responses[1]["result"].is_null(), + "batch new-method pending should return null" + ); + } + #[tokio::test] async fn test_enabled_batch_pool_lookup_returns_null() { let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); @@ -1070,6 +1516,36 @@ mod tests { ); } + #[tokio::test] + async fn test_disabled_allows_new_pending_block_methods() { + let layer = ArcRpcLayer { + filter_pending_txs: false, + ..Default::default() + }; + let middleware = layer.layer(MockRpcService); + let methods = [ + ETH_GET_BLOCK_RECEIPTS_METHOD, + ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, + ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, + ]; + for method in methods { + let params = RawValue::from_string(r#"["pending"]"#.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "filter_pending_txs=false should allow {method}" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "filter_pending_txs=false should forward {method}" + ); + } + } + // ── ArcRpcLayer::default() ────────────────────────────────────────── #[test] @@ -1476,4 +1952,547 @@ mod tests { let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); assert_eq!(json["result"], "success"); } + + // ── Case-insensitive "pending" tag + additional block-content methods ── + // + // All six block-content methods accept a block number/tag as first param. + // When that tag is "pending" (any case) the middleware returns null. + + #[tokio::test] + async fn test_pending_block_methods_all_tags_return_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases = [ + (ETH_GET_BLOCK_BY_NUMBER_METHOD, "pending"), + (ETH_GET_BLOCK_BY_NUMBER_METHOD, "Pending"), + (ETH_GET_BLOCK_BY_NUMBER_METHOD, "PENDING"), + (ETH_GET_BLOCK_RECEIPTS_METHOD, "pending"), + (ETH_GET_BLOCK_RECEIPTS_METHOD, "Pending"), + (ETH_GET_BLOCK_RECEIPTS_METHOD, "PENDING"), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, "pending"), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, "Pending"), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, "PENDING"), + (ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "pending"), + (ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "Pending"), + (ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "PENDING"), + (ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "pending"), + (ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "Pending"), + (ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "PENDING"), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, "pending"), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, "Pending"), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, "PENDING"), + (ETH_GET_HEADER_BY_NUMBER_METHOD, "pending"), + (ETH_GET_HEADER_BY_NUMBER_METHOD, "Pending"), + (ETH_GET_HEADER_BY_NUMBER_METHOD, "PENDING"), + ]; + for (method, tag) in cases { + let params = RawValue::from_string(format!("[\"{tag}\"]")).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "{method} with \"{tag}\" should not error" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert!( + json["result"].is_null(), + "{method} with \"{tag}\" should return null" + ); + } + } + + #[tokio::test] + async fn test_additional_pending_block_methods_non_pending_tag_passes_through() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[(&str, &str)] = &[ + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"["latest"]"#), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, r#"["latest"]"#), + ( + ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"["0x1", "0x0"]"#, + ), + ( + ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"["0x1", "0x0"]"#, + ), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, r#"["latest"]"#), + (ETH_GET_HEADER_BY_NUMBER_METHOD, r#"["latest"]"#), + ]; + for (method, params_json) in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + + assert!( + response.as_error_code().is_none(), + "{method} with non-pending tag should pass through" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "{method} with non-pending tag should return inner service response" + ); + } + } + + // ── Object-style params bypass ────────────────────────────────────── + // + // jsonrpsee handlers accept both array and object params. The middleware + // must intercept object-style pending-block queries too. + + #[tokio::test] + async fn test_object_params_pending_block_returns_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[(&str, &str)] = &[ + // Five methods use "number" key + ( + ETH_GET_BLOCK_BY_NUMBER_METHOD, + r#"{"number": "pending", "full": false}"#, + ), + ( + ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, + r#"{"number": "pending"}"#, + ), + ( + ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"{"number": "pending", "index": "0x0"}"#, + ), + ( + ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"{"number": "pending", "index": "0x0"}"#, + ), + ( + ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, + r#"{"number": "pending"}"#, + ), + // eth_getBlockReceipts uses "block_id" (snake) or "blockId" (camel) + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"{"block_id": "pending"}"#), + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"{"blockId": "pending"}"#), + // eth_getHeaderByNumber uses "hash" (Reth's proc-macro arg name) + (ETH_GET_HEADER_BY_NUMBER_METHOD, r#"{"hash": "pending"}"#), + // Case-insensitive variant via object params — guards that BlockNumberOrTag deserialization lowercases tags + ( + ETH_GET_BLOCK_BY_NUMBER_METHOD, + r#"{"number": "PENDING", "full": false}"#, + ), + ]; + for (method, params_json) in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "{method} with object params should not error" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert!( + json["result"].is_null(), + "{method} with object params {{...\"pending\"...}} should return null" + ); + } + } + + #[tokio::test] + async fn test_object_params_non_pending_passes_through() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[(&str, &str)] = &[ + ( + ETH_GET_BLOCK_BY_NUMBER_METHOD, + r#"{"number": "0x1", "full": false}"#, + ), + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"{"block_id": "latest"}"#), + (ETH_GET_HEADER_BY_NUMBER_METHOD, r#"{"hash": "latest"}"#), + // Unknown field → passes through (Reth returns parse error, not middleware) + (ETH_GET_BLOCK_BY_NUMBER_METHOD, r#"{"garbage": "pending"}"#), + ]; + for (method, params_json) in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "{method} with non-pending object params should pass through" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "{method} with non-pending object params should reach inner service" + ); + } + } + + // ── EIP-1898 BlockId object form ──────────────────────────────────── + // + // eth_getBlockReceipts accepts BlockId, which allows an EIP-1898 object + // form in addition to the plain string form. {"blockNumber": "pending"} + // deserializes to BlockId::Number(Pending) and must also be intercepted. + + #[tokio::test] + async fn test_eip1898_block_receipts_pending_returns_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[&str] = &[ + // Array-style: element is an EIP-1898 object + r#"[{"blockNumber": "pending"}]"#, + // Named params (snake_case key) with EIP-1898 value + r#"{"block_id": {"blockNumber": "pending"}}"#, + // Named params (camelCase key) with EIP-1898 value + r#"{"blockId": {"blockNumber": "pending"}}"#, + ]; + for params_json in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(ETH_GET_BLOCK_RECEIPTS_METHOD, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "eth_getBlockReceipts EIP-1898 pending should not error" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert!( + json["result"].is_null(), + "eth_getBlockReceipts with EIP-1898 pending ({params_json}) should return null" + ); + } + } + + #[tokio::test] + async fn test_eip1898_block_receipts_non_pending_passes_through() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[&str] = &[ + r#"[{"blockNumber": "0x1"}]"#, + r#"{"block_id": {"blockNumber": "0x1"}}"#, + ]; + for params_json in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(ETH_GET_BLOCK_RECEIPTS_METHOD, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "eth_getBlockReceipts EIP-1898 non-pending should pass through" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "eth_getBlockReceipts with EIP-1898 non-pending ({params_json}) should reach inner service" + ); + } + } + + // ── Transaction relay middleware ──────────────────────────────────── + + /// Inner service that counts `call` invocations, for asserting local pool adds. + #[derive(Clone, Debug)] + struct CountingRpcService(Arc); + + impl RpcServiceT for CountingRpcService { + type MethodResponse = MethodResponse; + type NotificationResponse = MethodResponse; + type BatchResponse = MethodResponse; + + #[allow(clippy::manual_async_fn)] + fn call<'a>(&self, req: Request<'a>) -> impl Future + Send + 'a { + let counter = self.0.clone(); + async move { + counter.fetch_add(1, Ordering::Relaxed); + let payload = ResponsePayload::success(serde_json::Value::String("local".into())); + MethodResponse::response(req.id(), payload.into(), usize::MAX) + } + } + + #[allow(clippy::manual_async_fn)] + fn batch<'a>(&self, req: Batch<'a>) -> impl Future + Send + 'a { + let service = self.clone(); + async move { + let mut builder = BatchResponseBuilder::new_with_limit(usize::MAX); + for entry in req { + match entry { + Ok(BatchEntry::Call(request)) => { + service.0.fetch_add(1, Ordering::Relaxed); + let payload = + ResponsePayload::success(serde_json::Value::String("local".into())); + builder + .append(MethodResponse::response( + request.id(), + payload.into(), + usize::MAX, + )) + .unwrap(); + } + Ok(BatchEntry::Notification(n)) => { + let _ = service.notification(n).await; + } + Err(err) => { + let (error, id) = err.into_parts(); + builder.append(MethodResponse::error(id, error)).unwrap(); + } + } + } + MethodResponse::from_batch(builder.finish()) + } + } + + #[allow(clippy::manual_async_fn)] + fn notification<'a>( + &self, + _n: Notification<'a>, + ) -> impl Future + Send + 'a { + async move { MethodResponse::notification() } + } + } + + fn mock_upstream(result: &str) -> RpcClient { + let asserter = Asserter::new(); + asserter.push_success(&result); + RpcClient::mocked(asserter) + } + + fn failing_upstream(msg: &str) -> RpcClient { + let asserter = Asserter::new(); + asserter.push_failure_msg(msg.to_string()); + RpcClient::mocked(asserter) + } + + /// Client pointed at a closed port: connecting yields an immediate transport error. + fn unreachable_upstream() -> RpcClient { + RpcClient::new_http("http://127.0.0.1:1".parse().unwrap()) + } + + fn relays_with(clients: Vec) -> TxRelays { + TxRelays { + clients: clients.into(), + sticky: Arc::new(AtomicUsize::new(0)), + max_response_body_size: usize::MAX, + request_timeout: DEFAULT_TX_RELAY_TIMEOUT, + } + } + + fn result_of(resp: MethodResponse) -> serde_json::Value { + let json: serde_json::Value = serde_json::from_str(resp.into_json().get()).unwrap(); + json["result"].clone() + } + + const RAW_TX_HEX: &str = "0xdeadbeef"; + + #[test] + fn should_failover_classifies_http_status() { + // 5xx and 429 are transient: advance to the next upstream. + assert!(should_failover(&TransportErrorKind::http_error( + 500, + String::new() + ))); + assert!(should_failover(&TransportErrorKind::http_error( + 503, + String::new() + ))); + assert!(should_failover(&TransportErrorKind::http_error( + 429, + String::new() + ))); + // Other 4xx are the upstream's verdict, not an outage: return verbatim. + assert!(!should_failover(&TransportErrorKind::http_error( + 400, + String::new() + ))); + assert!(!should_failover(&TransportErrorKind::http_error( + 403, + String::new() + ))); + } + + #[test] + fn relay_error_category_never_leaks_upstream_url() { + // Relay URLs can carry API keys in path or query; the logged category + // must expose only the failure shape, never the URL or response body. + const SECRET: &str = "relay-secret-sentinel"; + let leaky_body = format!("https://upstream/v3/{SECRET}?api_key={SECRET}"); + + let category = relay_error_category(&TransportErrorKind::http_error(503, leaky_body)); + assert_eq!(category, "http status 503"); + assert!(!category.contains(SECRET)); + } + + #[tokio::test] + async fn relay_forwards_to_upstream_and_adds_locally() { + let counter = Arc::new(AtomicUsize::new(0)); + let mw = TxRelayMiddleware { + service: CountingRpcService(counter.clone()), + relays: relays_with(vec![mock_upstream("0xupstream")]), + }; + let resp = mw.call(send_raw_tx_request(RAW_TX_HEX, 1)).await; + assert_eq!(result_of(resp), serde_json::json!("0xupstream")); + assert_eq!( + counter.load(Ordering::Relaxed), + 1, + "tx should be added to local pool" + ); + assert_eq!(mw.relays.sticky.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn relay_intercepts_sync_method() { + let counter = Arc::new(AtomicUsize::new(0)); + let mw = TxRelayMiddleware { + service: CountingRpcService(counter.clone()), + relays: relays_with(vec![mock_upstream("0xreceipt")]), + }; + let resp = mw.call(send_raw_tx_sync_request(RAW_TX_HEX, 1)).await; + assert_eq!(result_of(resp), serde_json::json!("0xreceipt")); + assert_eq!( + counter.load(Ordering::Relaxed), + 1, + "sync submission should still add locally" + ); + } + + #[tokio::test] + async fn relay_fails_over_on_transport_error() { + let counter = Arc::new(AtomicUsize::new(0)); + let mw = TxRelayMiddleware { + service: CountingRpcService(counter.clone()), + relays: relays_with(vec![unreachable_upstream(), mock_upstream("0xsecond")]), + }; + let resp = mw.call(send_raw_tx_request(RAW_TX_HEX, 1)).await; + assert_eq!(result_of(resp), serde_json::json!("0xsecond")); + assert_eq!( + mw.relays.sticky.load(Ordering::Relaxed), + 1, + "sticky should advance to the good upstream" + ); + assert_eq!(counter.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn relay_sticky_starts_at_cursor() { + let mw = TxRelayMiddleware { + service: CountingRpcService(Arc::new(AtomicUsize::new(0))), + relays: relays_with(vec![mock_upstream("0xfirst"), mock_upstream("0xsecond")]), + }; + mw.relays.sticky.store(1, Ordering::Relaxed); + let resp = mw.call(send_raw_tx_request(RAW_TX_HEX, 1)).await; + assert_eq!( + result_of(resp), + serde_json::json!("0xsecond"), + "should start at the sticky upstream, not index 0" + ); + } + + #[tokio::test] + async fn relay_advance_wraps_past_end() { + let mw = TxRelayMiddleware { + service: CountingRpcService(Arc::new(AtomicUsize::new(0))), + relays: relays_with(vec![mock_upstream("0xfirst"), unreachable_upstream()]), + }; + mw.relays.sticky.store(1, Ordering::Relaxed); + let resp = mw.call(send_raw_tx_request(RAW_TX_HEX, 1)).await; + assert_eq!( + result_of(resp), + serde_json::json!("0xfirst"), + "advance from last upstream should wrap to index 0" + ); + assert_eq!(mw.relays.sticky.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn relay_all_unreachable_errors_without_local_add() { + let counter = Arc::new(AtomicUsize::new(0)); + let mw = TxRelayMiddleware { + service: CountingRpcService(counter.clone()), + relays: relays_with(vec![unreachable_upstream(), unreachable_upstream()]), + }; + let resp = mw.call(send_raw_tx_request(RAW_TX_HEX, 1)).await; + assert_eq!(resp.as_error_code(), Some(RELAY_UNAVAILABLE_ERROR_CODE)); + assert_eq!( + counter.load(Ordering::Relaxed), + 0, + "no local add when all upstreams fail" + ); + } + + #[tokio::test] + async fn relay_returns_upstream_error_verbatim_without_failover() { + let counter = Arc::new(AtomicUsize::new(0)); + let mw = TxRelayMiddleware { + service: CountingRpcService(counter.clone()), + relays: relays_with(vec![ + failing_upstream("nonce too low"), + mock_upstream("0xsecond"), + ]), + }; + let resp = mw.call(send_raw_tx_request(RAW_TX_HEX, 1)).await; + assert!( + resp.as_error_code().is_some(), + "reachable-upstream error must be returned, not failed over" + ); + let json: serde_json::Value = serde_json::from_str(resp.into_json().get()).unwrap(); + assert_eq!(json["error"]["message"], "nonce too low"); + assert_eq!( + counter.load(Ordering::Relaxed), + 0, + "rejected tx must not be added locally" + ); + assert_eq!( + mw.relays.sticky.load(Ordering::Relaxed), + 0, + "must stick to the reachable upstream" + ); + } + + #[tokio::test] + async fn relay_extracts_object_form_params() { + let mw = TxRelayMiddleware { + service: CountingRpcService(Arc::new(AtomicUsize::new(0))), + relays: relays_with(vec![mock_upstream("0xok")]), + }; + let resp = mw.call(send_raw_tx_object_request(RAW_TX_HEX, 1)).await; + assert_eq!(result_of(resp), serde_json::json!("0xok")); + } + + #[tokio::test] + async fn relay_passes_through_non_raw_tx_methods() { + let counter = Arc::new(AtomicUsize::new(0)); + // Upstream is unreachable; a non-relayed method must never touch it. + let mw = TxRelayMiddleware { + service: CountingRpcService(counter.clone()), + relays: relays_with(vec![unreachable_upstream()]), + }; + let params = RawValue::from_string("[]".to_string()).unwrap(); + let req = create_request_with_params("eth_blockNumber", params, 1); + let resp = mw.call(req).await; + assert_eq!(result_of(resp), serde_json::json!("local")); + assert_eq!(counter.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn arc_rpc_layer_relays_when_configured() { + let layer = ArcRpcLayer::new( + true, + false, + usize::MAX, + ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, + vec!["http://127.0.0.1:1".to_string()], + DEFAULT_TX_RELAY_TIMEOUT, + ); + let middleware = layer.layer(MockRpcService); + // EIP-155-protected tx clears the replay guard; the sole upstream is unreachable. + let resp = middleware + .call(send_raw_tx_request(&encode_legacy_raw(Some(1)), 1)) + .await; + assert_eq!(resp.as_error_code(), Some(RELAY_UNAVAILABLE_ERROR_CODE)); + } + + #[tokio::test] + async fn arc_rpc_layer_without_relays_passes_through() { + let layer = ArcRpcLayer::new( + true, + false, + usize::MAX, + ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, + Vec::new(), + DEFAULT_TX_RELAY_TIMEOUT, + ); + assert!(layer.tx_relays.is_none()); + let middleware = layer.layer(MockRpcService); + let resp = middleware + .call(send_raw_tx_request(&encode_legacy_raw(Some(1)), 1)) + .await; + assert_eq!(result_of(resp), serde_json::json!("success")); + } } diff --git a/crates/evm-specs-tests/src/adapter.rs b/crates/evm-specs-tests/src/adapter.rs index e99fc823..2d2df68b 100644 --- a/crates/evm-specs-tests/src/adapter.rs +++ b/crates/evm-specs-tests/src/adapter.rs @@ -174,6 +174,7 @@ mod tests { current_beacon_root: None, current_withdrawals_root: None, current_excess_blob_gas: None, + slot_number: None, }, pre: alloy_primitives::map::HashMap::default(), post: BTreeMap::default(), diff --git a/crates/evm-specs-tests/src/roots.rs b/crates/evm-specs-tests/src/roots.rs index 440a32b0..8310fc7b 100644 --- a/crates/evm-specs-tests/src/roots.rs +++ b/crates/evm-specs-tests/src/roots.rs @@ -168,10 +168,11 @@ impl Hasher for KeccakHasher { } } -pub fn compute_state_root_from_fixture_accounts( - accounts: &alloy_primitives::map::HashMap, -) -> B256 { - state_root_unhashed(accounts.iter().map(|(address, account)| { +pub fn compute_state_root_from_fixture_accounts<'a, I>(accounts: I) -> B256 +where + I: IntoIterator, +{ + state_root_unhashed(accounts.into_iter().map(|(address, account)| { let storage_root = if account.storage.is_empty() { EMPTY_ROOT_HASH } else { @@ -252,7 +253,7 @@ mod tests { #[test] fn revm_state_root_ignores_zero_storage_slots() { let address = address!("2000000000000000000000000000000000000002"); - let mut cache = CacheState::new(true); + let mut cache = CacheState::new(); cache.insert_account_with_storage( address, AccountInfo::default(), @@ -264,21 +265,20 @@ mod tests { let root = state_merkle_trie_root(cache.trie_account()); - let expected = - compute_state_root_from_fixture_accounts(&alloy_primitives::map::HashMap::from_iter([ - ( - address, - FixtureAccountInfo { - balance: U256::ZERO, - code: Bytes::default(), - nonce: 0, - storage: alloy_primitives::map::HashMap::from_iter([( - U256::from(2), - U256::from(22), - )]), - }, - ), - ])); + let expected_accounts: alloy_primitives::map::HashMap = + alloy_primitives::map::HashMap::from_iter([( + address, + FixtureAccountInfo { + balance: U256::ZERO, + code: Bytes::default(), + nonce: 0, + storage: alloy_primitives::map::HashMap::from_iter([( + U256::from(2), + U256::from(22), + )]), + }, + )]); + let expected = compute_state_root_from_fixture_accounts(&expected_accounts); assert_eq!(root, expected); } @@ -286,7 +286,7 @@ mod tests { #[test] fn state_root_ignores_empty_accounts() { let address = address!("3000000000000000000000000000000000000003"); - let mut cache = CacheState::new(true); + let mut cache = CacheState::new(); cache.insert_account(address, AccountInfo::default()); assert_eq!( @@ -325,7 +325,7 @@ mod tests { ..Default::default() }; - let mut cache_with_arc = CacheState::new(true); + let mut cache_with_arc = CacheState::new(); for arc in ARC_SYSTEM_ADDRESSES { cache_with_arc.insert_account( *arc, @@ -345,7 +345,7 @@ mod tests { .filter(|(address, _)| !is_arc_system_address(address)), ); - let mut cache_user_only = CacheState::new(true); + let mut cache_user_only = CacheState::new(); cache_user_only.insert_account(user_address, user_info); let expected_root = state_merkle_trie_root(cache_user_only.trie_account()); @@ -355,7 +355,8 @@ mod tests { #[test] fn fixture_state_root_matches_expected_fixture_accounts() { - let mut accounts = alloy_primitives::map::HashMap::default(); + let mut accounts: alloy_primitives::map::HashMap = + alloy_primitives::map::HashMap::default(); accounts.insert( address!("1000000000000000000000000000000000000001"), FixtureAccountInfo { diff --git a/crates/evm-specs-tests/src/runner.rs b/crates/evm-specs-tests/src/runner.rs index c145f3fb..6a9ed019 100644 --- a/crates/evm-specs-tests/src/runner.rs +++ b/crates/evm-specs-tests/src/runner.rs @@ -911,7 +911,7 @@ fn build_state_root_diagnostic( ); } - let oracle_root = compute_state_root_from_fixture_accounts(&ctx.test.post_state); + let oracle_root = compute_state_root_from_fixture_accounts(ctx.test.post_state.iter()); let expected_trie_accounts: BTreeSet<_> = ctx.test.post_state.keys().copied().collect(); let extra_actual_accounts = actual_trie_accounts .difference(&expected_trie_accounts) @@ -961,7 +961,7 @@ fn build_json_outcome( gas_used: exec_result .as_ref() .ok() - .map(|result| result.gas_used()) + .map(|result| result.tx_gas_used()) .unwrap_or_default(), error_msg: error.unwrap_or_default(), evm_result: format_evm_result(exec_result), @@ -1281,7 +1281,7 @@ mod tests { use reth_evm::EvmEnv; use revm::context::result::{ EVMError, ExecutionResult, HaltReason, InvalidTransaction, OutOfGasError, Output, - SuccessReason, + ResultGas, SuccessReason, }; use revm::database::{EmptyDB, State}; use revm::state::AccountInfo; @@ -1293,7 +1293,7 @@ mod tests { }; fn touched_state(addresses: &[Address]) -> State { - let mut cache = CacheState::new(true); + let mut cache = CacheState::new(); for address in addresses { cache.insert_account(*address, AccountInfo::default()); } @@ -1344,6 +1344,7 @@ mod tests { current_beacon_root: None, current_withdrawals_root: None, current_excess_blob_gas: None, + slot_number: None, }, pre: alloy_primitives::map::HashMap::default(), post, @@ -1377,7 +1378,7 @@ mod tests { ) -> TestExecutionContext<'a> { let factory = crate::adapter::build_evm_factory(crate::adapter::build_default_arc_chain_spec()); - let cache_state = CacheState::new(true); + let cache_state = CacheState::new(); TestExecutionContext { factory: Box::leak(Box::new(factory)), @@ -1392,8 +1393,7 @@ mod tests { fn call_result(output: Bytes) -> ExecutionResult { ExecutionResult::Success { reason: SuccessReason::Return, - gas_used: 21_000, - gas_refunded: 0, + gas: ResultGas::new_with_state_gas(21_000, 0, 0, 0), logs: Vec::new(), output: Output::Call(output), } @@ -1401,7 +1401,8 @@ mod tests { fn revert_result(output: Bytes) -> ExecutionResult { ExecutionResult::Revert { - gas_used: 21_000, + gas: ResultGas::new_with_state_gas(21_000, 0, 0, 0), + logs: Vec::new(), output, } } @@ -1671,7 +1672,8 @@ mod tests { unit.out.as_ref(), &ExecutionResult::Halt { reason: HaltReason::OutOfGas(OutOfGasError::Basic), - gas_used: 21_000, + gas: ResultGas::new_with_state_gas(21_000, 0, 0, 0), + logs: Vec::new(), }, ) .expect("halted executions do not have output to compare"); @@ -1708,7 +1710,8 @@ mod tests { assert_eq!( format_evm_result(&Ok(ExecutionResult::Halt { reason: HaltReason::OutOfGas(OutOfGasError::Basic), - gas_used: 21_000, + gas: ResultGas::new_with_state_gas(21_000, 0, 0, 0), + logs: Vec::new(), })), "Halt: OutOfGas(Basic)" ); diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index 9b1844d9..f45124fc 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -44,6 +44,7 @@ reth-primitives-traits.workspace = true # revm revm.workspace = true revm-context-interface.workspace = true +revm-handler.workspace = true revm-interpreter.workspace = true revm-primitives.workspace = true diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index ce4324b1..1735d184 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -19,11 +19,11 @@ extern crate alloc; use crate::assembler::ArcBlockAssembler; use crate::executor::ArcBlockExecutor; use crate::frame_result::{create_frame_result, create_oog_frame_result, BeforeFrameInitResult}; -use crate::log::{create_eip7708_transfer_log, create_native_transfer_log}; +use crate::log::create_eip7708_transfer_log; use alloc::sync::Arc; use alloy_evm::eth::EthEvmContext; use alloy_evm::{ - block::{BlockExecutorFactory, BlockExecutorFor}, + block::{state::StateDB, BlockExecutorFactory}, eth::EthBlockExecutionCtx, precompiles::PrecompilesMap, Evm as AlloyEvmTrait, EvmFactory, @@ -35,7 +35,6 @@ use arc_execution_config::native_coin_control::{ }; use arc_precompiles::helpers::{ ERR_BLOCKED_ADDRESS, ERR_SELFDESTRUCTED_BALANCE_INCREASED, ERR_ZERO_ADDRESS, - PRECOMPILE_SLOAD_GAS_COST, }; use arc_precompiles::NATIVE_COIN_CONTROL_ADDRESS; use core::fmt::Debug; @@ -43,7 +42,7 @@ use reth_ethereum::evm::revm::context::block::BlockEnv; use reth_ethereum::evm::revm::primitives::U256; use reth_ethereum::{ evm::{ - primitives::{Database, EvmEnv, InspectorFor, NextBlockEnvAttributes}, + primitives::{Database, EvmEnv, NextBlockEnvAttributes}, revm::{ context::{Context, ContextTr, JournalTr, TxEnv}, context_interface::result::{EVMError, HaltReason}, @@ -55,14 +54,14 @@ use reth_ethereum::{ EthEvmConfig, }, node::api::ConfigureEvm, - primitives::{Header, SealedBlock, SealedHeader}, + primitives::{Header, Log, SealedBlock, SealedHeader}, Receipt, TransactionSigned, }; use reth_evm::execute::BlockBuilder; +use reth_evm::BlockExecutorForEvm; use reth_evm::{ConfigureEngineEvm, EvmEnvFor, ExecutableTxIterator, ExecutionCtxFor}; use reth_primitives_traits::NodePrimitives; use revm::bytecode::opcode::SELFDESTRUCT; -use revm::bytecode::Bytecode; use revm::context_interface::result::ResultAndState; use revm::handler::evm::{ContextDbError, FrameInitResult}; use revm::handler::instructions::InstructionProvider; @@ -71,18 +70,20 @@ use revm::inspector::{InspectorEvmTr, InspectorHandler, JournalExt}; use revm::state::AccountInfo; use revm::Database as RevmDatabase; use revm::ExecuteEvm; +use revm::SystemCallEvm; use revm::{ context::{ result::{ExecResultAndState, ExecutionResult, InvalidTransaction}, ContextSetters, Evm as RevmEvm, }, - handler::{instructions::EthInstructions, EthFrame, PrecompileProvider, SystemCallTx}, + handler::{instructions::EthInstructions, EthFrame, PrecompileProvider}, interpreter::{CallOutcome, Gas, InstructionResult, InterpreterResult}, state::EvmState, - InspectEvm, SystemCallEvm, + InspectEvm, }; use revm_context_interface::journaled_state::{JournalCheckpoint, JournalLoadError}; use revm_context_interface::{FrameStack, Transaction}; +use revm_handler::SystemCallTx; use revm_interpreter::interpreter_action::FrameInit; use revm_interpreter::{CallScheme, CreateScheme, FrameInput, Instruction}; use revm_primitives::{Address, Bytes}; @@ -90,7 +91,7 @@ use std::collections::{HashMap, HashSet}; use crate::handler::ArcEvmHandler; use crate::opcode::{ - arc_network_selfdestruct_zero4, arc_network_selfdestruct_zero5, arc_network_selfdestruct_zero7, + arc_network_selfdestruct, arc_network_selfdestruct_zero7, arc_network_selfdestruct_zero8, }; use crate::subcall::{SubcallContinuation, SubcallRegistry}; use arc_execution_config::chainspec::{ArcChainSpec, BlockGasLimitProvider}; @@ -105,12 +106,34 @@ use revm::interpreter::interpreter_action::CallInputs; /// calls. Prevents zero-cost probing of subcall precompile addresses. const SUBCALL_DISPATCH_COST: u64 = 100; +fn insert_arc_selfdestruct_instruction( + instruction: &mut EthInstructions>, + hardfork_flags: ArcHardforkFlags, +) { + if hardfork_flags.is_active(ArcHardfork::Zero8) { + instruction.insert_instruction( + SELFDESTRUCT, + Instruction::new(arc_network_selfdestruct_zero8, 5000), + ); + } else if hardfork_flags.is_active(ArcHardfork::Zero7) { + instruction.insert_instruction( + SELFDESTRUCT, + Instruction::new(arc_network_selfdestruct_zero7, 5000), + ); + } else { + instruction.insert_instruction( + SELFDESTRUCT, + Instruction::new(arc_network_selfdestruct, 5000), + ); + } +} + /// Construct a revert `FrameResult` for a subcall precompile rejection. fn init_subcall_revert(message: &str, call_inputs: &CallInputs) -> FrameResult { let revert_bytes = arc_precompiles::helpers::revert_message_to_bytes(message); let mut gas = Gas::new(call_inputs.gas_limit); // Charge a flat dispatch cost. If the caller doesn't have enough gas, consume all of it. - if !gas.record_cost(SUBCALL_DISPATCH_COST) { + if !gas.record_regular_cost(SUBCALL_DISPATCH_COST) { gas.spend_all(); } let result = InterpreterResult::new(InstructionResult::Revert, revert_bytes, gas); @@ -167,7 +190,7 @@ fn load_account_with_code_metered( } else { revm_interpreter::gas::WARM_STORAGE_READ_COST }; - if !gas.record_cost(cost) { + if !gas.record_regular_cost(cost) { return Ok(None); } Ok(Some(info.account.into_owned())) @@ -191,7 +214,7 @@ pub struct ArcEvm> { } /// ArcEvm implementation, wrapping an inner revm EVM instance to apply handler -/// 1. Hook frame_init to add the NativeCoinTransferred event log. +/// 1. Hook frame_init to add the EIP-7708 Transfer event log. /// 2. Add the blocklist check for each frame. /// 3. Check static context to the precompiles. impl ArcEvm, P> { @@ -364,7 +387,7 @@ where let res = EthFrame::init_with_context(new_frame, ctx, precompiles, frame_input)?; - Ok(res.map_frame(|token| { + Ok(res.map_item(|token| { if is_first_init { unsafe { frame_stack.end_init(token) }; } else { @@ -374,6 +397,15 @@ where })) } +fn frame_init_outcome( + frame_res: FrameInitResult<'_, EthFrame>, +) -> FrameInitOutcome { + match frame_res { + ItemOrResult::Item(_) => FrameInitOutcome::Pushed, + ItemOrResult::Result(result) => FrameInitOutcome::Immediate(result), + } +} + /// ArcEvm implementation for customized operations. impl ArcEvm { /// Checks if an address is blocklisted by reading from the native coin control precompile storage. @@ -403,14 +435,10 @@ impl ArcEvm { } fn sload_cost(&self, is_cold: bool) -> u64 { - if self.hardfork_flags.is_active(ArcHardfork::Zero6) { - if is_cold { - revm_interpreter::gas::COLD_SLOAD_COST - } else { - revm_interpreter::gas::WARM_STORAGE_READ_COST - } + if is_cold { + revm_interpreter::gas::COLD_SLOAD_COST } else { - PRECOMPILE_SLOAD_GAS_COST + revm_interpreter::gas::WARM_STORAGE_READ_COST } } @@ -479,16 +507,14 @@ impl ArcEvm { // Currently SLOAD gas metering is disabled — blocklist checks are unmetered. let meter_sloads = false; - // Zero5: reject CALL/CREATE value transfers involving the zero address. + // Reject CALL/CREATE value transfers involving the zero address. // This prevents accidental burn/mint semantics at the EVM execution layer. // // Note: this check only applies to CALL/CREATE frame value transfers — it does NOT // affect the NativeCoinAuthority precompile, which legitimately uses Address::ZERO in // ERC-20 Transfer events for mint (from=0x0) and burn (to=0x0). The precompile operates // via direct journal balance mutations within its own frame, never triggering frame_init. - if self.hardfork_flags.is_active(ArcHardfork::Zero5) - && (from == Address::ZERO || to == Address::ZERO) - { + if from == Address::ZERO || to == Address::ZERO { return Ok(BeforeFrameInitResult::Reverted(create_frame_result( frame_input, ERR_ZERO_ADDRESS, @@ -523,48 +549,39 @@ impl ArcEvm { ERR_BLOCKED_ADDRESS, )); } - if self.hardfork_flags.is_active(ArcHardfork::Zero5) { - // Probe inside a checkpoint so this unmetered selfdestructed-target check does not - // leave a previously-cold account warm for the later path. - let target_is_selfdestructed = { - let journal = self.inner.journal_mut(); - if self.hardfork_flags.is_active(ArcHardfork::Zero7) { - let checkpoint = journal.checkpoint(); - let result = journal - .load_account(to) - .map(|target_account| target_account.is_selfdestructed()); - journal.checkpoint_revert(checkpoint); - result? - } else { - journal - .load_account(to) - .map(|target_account| target_account.is_selfdestructed())? - } - }; - - if target_is_selfdestructed { - return Ok(metered_revert( - frame_input, - meter_sloads, - total_sload_cost, - ERR_SELFDESTRUCTED_BALANCE_INCREASED, - )); + // Probe inside a checkpoint once the cold-state preserving behavior is active, so this + // unmetered selfdestructed-target check does not leave a previously-cold account warm. + let target_is_selfdestructed = { + let journal = self.inner.journal_mut(); + if self.hardfork_flags.is_active(ArcHardfork::Zero7) { + let checkpoint = journal.checkpoint(); + let result = journal + .load_account(to) + .map(|target_account| target_account.is_selfdestructed()); + journal.checkpoint_revert(checkpoint); + result? + } else { + journal + .load_account(to) + .map(|target_account| target_account.is_selfdestructed())? } + }; + + if target_is_selfdestructed { + return Ok(metered_revert( + frame_input, + meter_sloads, + total_sload_cost, + ERR_SELFDESTRUCTED_BALANCE_INCREASED, + )); } - if self.hardfork_flags.is_active(ArcHardfork::Zero5) { - if from == to { - // EIP-7708: self-transfers do not emit a log - Ok(BeforeFrameInitResult::Checked(total_sload_cost)) - } else { - Ok(BeforeFrameInitResult::Log( - create_eip7708_transfer_log(from, to, amount), - total_sload_cost, - )) - } + if from == to { + // EIP-7708: self-transfers do not emit a log. + Ok(BeforeFrameInitResult::Checked(total_sload_cost)) } else { Ok(BeforeFrameInitResult::Log( - create_native_transfer_log(from, to, amount), + create_eip7708_transfer_log(from, to, amount), total_sload_cost, )) } @@ -768,7 +785,7 @@ where /// Runs `before_frame_init` (blocklist checks, transfer log), then initializes the /// frame via revm's standard machinery and emits the transfer log on success. /// - /// For Zero5+ precompile CALLs, the EIP-7708 Transfer log is pushed before frame init + /// For precompile CALLs, the EIP-7708 Transfer log is pushed before frame init /// (wrapped in a journal checkpoint) so it precedes precompile-emitted logs. For all /// other cases, the log is pushed after frame init only on success. /// @@ -789,69 +806,74 @@ where // Log emission strategy for the EIP-7708 Transfer log: // - // **Zero5+ precompile CALL**: Push the Transfer log BEFORE init_frame so it + // **Precompile CALL**: Push the Transfer log BEFORE init_frame so it // precedes any logs the precompile emits (EIP-7708 requires the native Transfer // log to appear before precompile-emitted logs). The precompile runs synchronously // inside init_with_context and returns a Result, so a journal checkpoint can // correctly commit/revert based on the outcome. // - // **All other cases** (non-precompile CALLs/CREATEs at any hardfork, and pre-Zero5 - // precompile CALLs): Execute first, then push the log only if the result indicates + // **All other cases** (non-precompile CALLs/CREATEs): Execute first, then push the log only if the result indicates // success. For non-precompile frames that return Item (pending execution), REVM's // internal frame checkpoint already covers this log — if the frame later reverts, // logs.truncate(log_i) removes it automatically. - let is_precompile = is_precompile_call(&frame_input, &self.inner.precompiles); - - let frame_res = if is_precompile && self.hardfork_flags.is_active(ArcHardfork::Zero5) { - // Zero5+ precompile path: push the Transfer log BEFORE init_frame so it - // precedes any logs the precompile emits, wrapped in a journal checkpoint so - // the log is reverted if the precompile fails. - let log_checkpoint = if let Some(log) = maybe_log { - let cp = self.inner.ctx.journal_mut().checkpoint(); - self.inner.ctx.journal_mut().log(log); - Some(cp) - } else { - None - }; - - let res = init_frame( - &mut self.inner.frame_stack, - &mut self.inner.ctx, - &mut self.inner.precompiles, - frame_input, - )?; - - if let Some(cp) = log_checkpoint { - if should_emit_transfer_log(&res) { - self.inner.ctx.journal_mut().checkpoint_commit(); - } else { - self.inner.ctx.journal_mut().checkpoint_revert(cp); - } - } + if is_precompile_call(&frame_input, &self.inner.precompiles) { + self.init_precompile_frame_with_transfer_log(frame_input, maybe_log) + } else { + self.init_deferred_frame_with_transfer_log(frame_input, maybe_log) + } + } - res + fn init_precompile_frame_with_transfer_log( + &mut self, + frame_input: FrameInit, + maybe_log: Option, + ) -> Result> { + // Push the Transfer log before init_frame so it precedes any logs the precompile emits. + let log_checkpoint = if let Some(log) = maybe_log { + let cp = self.inner.ctx.journal_mut().checkpoint(); + self.inner.ctx.journal_mut().log(log); + Some(cp) } else { - // Common path: execute first, then push the log only if successful. - let res = init_frame( - &mut self.inner.frame_stack, - &mut self.inner.ctx, - &mut self.inner.precompiles, - frame_input, - )?; + None + }; - if let Some(log) = maybe_log { - if should_emit_transfer_log(&res) { - self.inner.ctx.journal_mut().log(log); - } + let res = init_frame( + &mut self.inner.frame_stack, + &mut self.inner.ctx, + &mut self.inner.precompiles, + frame_input, + )?; + + if let Some(cp) = log_checkpoint { + if should_emit_transfer_log(&res) { + self.inner.ctx.journal_mut().checkpoint_commit(); + } else { + self.inner.ctx.journal_mut().checkpoint_revert(cp); } + } - res - }; + Ok(frame_init_outcome(res)) + } + + fn init_deferred_frame_with_transfer_log( + &mut self, + frame_input: FrameInit, + maybe_log: Option, + ) -> Result> { + let res = init_frame( + &mut self.inner.frame_stack, + &mut self.inner.ctx, + &mut self.inner.precompiles, + frame_input, + )?; - match frame_res { - ItemOrResult::Item(_) => Ok(FrameInitOutcome::Pushed), - ItemOrResult::Result(result) => Ok(FrameInitOutcome::Immediate(result)), + if let Some(log) = maybe_log { + if should_emit_transfer_log(&res) { + self.inner.ctx.journal_mut().log(log); + } } + + Ok(frame_init_outcome(res)) } /// Intercept a call to a subcall-capable precompile and initialize the child frame. @@ -960,7 +982,7 @@ where // Access costs are charged explicitly because our pre-loads warm the accounts, // so revm's internal CALL handler won't charge them. let mut gas = Gas::new(call_inputs.gas_limit); - if !gas.record_cost(init_result.gas_overhead) { + if !gas.record_regular_cost(init_result.gas_overhead) { gas.spend_all(); return Ok(ItemOrResult::Result(subcall_oog( gas, @@ -981,11 +1003,11 @@ where ))); }; - // Resolve EIP-7702 delegation: if the target has a delegation designator, - // load the delegate's code so the child frame executes correct bytecode. - if let Some(Bytecode::Eip7702(delegation)) = &target.code { - let delegate_address = delegation.address(); - + // Populate known_bytecode for the child frame. If the target has an EIP-7702 + // delegation designator, follow it and use the delegate's code; otherwise use + // the target's own code. revm 38 requires this field to be set by the caller. + if let Some(delegate_address) = target.code.as_ref().and_then(|code| code.eip7702_address()) + { let Some(delegate) = load_account_with_code_metered( self.inner.ctx.journal_mut(), delegate_address, @@ -1000,8 +1022,10 @@ where }; if let Some(code) = delegate.code { - child_inputs.known_bytecode = Some((delegate.code_hash, code)); + child_inputs.known_bytecode = (delegate.code_hash, code); } + } else if let Some(code) = target.code { + child_inputs.known_bytecode = (target.code_hash, code); } // Take a journal checkpoint AFTER account loading so warmups always persist, @@ -1046,7 +1070,7 @@ where SubcallContinuation { precompile, gas_limit, - init_subcall_gas_overhead: gas.spent(), + init_subcall_gas_overhead: gas.total_gas_spent(), return_memory_offset, continuation_data: init_result.continuation_data, checkpoint, @@ -1060,7 +1084,7 @@ where let continuation = SubcallContinuation { precompile, gas_limit, - init_subcall_gas_overhead: gas.spent(), + init_subcall_gas_overhead: gas.total_gas_spent(), return_memory_offset, continuation_data: init_result.continuation_data, checkpoint, @@ -1127,8 +1151,8 @@ where // becomes OOG below instead of silently discarding that cost. let metered_gas_used = continuation .init_subcall_gas_overhead - .checked_add(child_gas.spent()) - .expect("gas overflow: init_subcall_overhead + child_gas.spent()") + .checked_add(child_gas.total_gas_spent()) + .expect("gas overflow: init_subcall_overhead + child_gas.total_gas_spent()") .checked_add(completion_gas) .expect("gas overflow: metered_gas_used + completion_gas"); let gas_used = if child_halted { @@ -1138,7 +1162,7 @@ where }; let mut gas = Gas::new(continuation.gas_limit); - if !gas.record_cost(gas_used) { + if !gas.record_regular_cost(gas_used) { gas.spend_all(); // If the child succeeded, its state was committed before completion @@ -1511,6 +1535,10 @@ where &self.inner.ctx.block } + fn cfg_env(&self) -> &revm::context::CfgEnv { + &self.inner.ctx.cfg + } + fn chain_id(&self) -> u64 { self.inner.ctx.cfg.chain_id } @@ -1550,6 +1578,9 @@ where .journal_mut() .load_account(NATIVE_COIN_CONTROL_ADDRESS)?; + // System calls skip handler pre_execution, so warm NativeCoinControl here. + // Running through ArcEvmHandler preserves system-call fee semantics while applying + // Arc frame/opcode hooks, including fail-closed blocklist reads during SELFDESTRUCT. self.inner .ctx .set_tx(TxEnv::new_system_tx_with_caller(caller, contract, data)); @@ -1665,10 +1696,14 @@ impl EvmFactory for ArcEvmFactory { type BlockEnv = BlockEnv; type Precompiles = PrecompilesMap; - fn create_evm(&self, db: DB, input: EvmEnv) -> Self::Evm { + fn create_evm(&self, db: DB, mut input: EvmEnv) -> Self::Evm { let spec = input.cfg_env.spec; let hardfork_flags = self.get_hardfork_flags(&input.block_env); + // Arc self-implements EIP-7708 log emission across all specs (see `create_eip7708_transfer_log`). + // Disable revm's native AMSTERDAM emission to avoid duplicate logs once the spec is enabled. + input.cfg_env.amsterdam_eip7708_disabled = true; + let ctx = Self::Context::new(db, spec) .with_cfg(input.cfg_env) .with_block(input.block_env); @@ -1677,22 +1712,7 @@ impl EvmFactory for ArcEvmFactory { let inspector = NoOpInspector {}; let subcall_registry = self.build_subcall_registry(hardfork_flags); - if hardfork_flags.is_active(ArcHardfork::Zero7) { - instruction.insert_instruction( - SELFDESTRUCT, - Instruction::new(arc_network_selfdestruct_zero7, 5000), - ); - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - instruction.insert_instruction( - SELFDESTRUCT, - Instruction::new(arc_network_selfdestruct_zero5, 5000), - ); - } else { - instruction.insert_instruction( - SELFDESTRUCT, - Instruction::new(arc_network_selfdestruct_zero4, 5000), - ); - } + insert_arc_selfdestruct_instruction(&mut instruction, hardfork_flags); ArcEvm::new( ctx, @@ -1708,12 +1728,14 @@ impl EvmFactory for ArcEvmFactory { fn create_evm_with_inspector, EthInterpreter>>( &self, db: DB, - input: EvmEnv, + mut input: EvmEnv, inspector: I, ) -> Self::Evm { let spec = input.cfg_env.spec; let hardfork_flags = self.get_hardfork_flags(&input.block_env); + input.cfg_env.amsterdam_eip7708_disabled = true; + let ctx = Self::Context::new(db, spec) .with_cfg(input.cfg_env) .with_block(input.block_env); @@ -1721,22 +1743,7 @@ impl EvmFactory for ArcEvmFactory { let precompiles = ArcPrecompileProvider::create_precompiles_map(spec, hardfork_flags); let subcall_registry = self.build_subcall_registry(hardfork_flags); - if hardfork_flags.is_active(ArcHardfork::Zero7) { - instruction.insert_instruction( - SELFDESTRUCT, - Instruction::new(arc_network_selfdestruct_zero7, 5000), - ); - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - instruction.insert_instruction( - SELFDESTRUCT, - Instruction::new(arc_network_selfdestruct_zero5, 5000), - ); - } else { - instruction.insert_instruction( - SELFDESTRUCT, - Instruction::new(arc_network_selfdestruct_zero4, 5000), - ); - } + insert_arc_selfdestruct_instruction(&mut instruction, hardfork_flags); ArcEvm::new( ctx, @@ -1776,6 +1783,16 @@ impl BlockExecutorFactory for ArcEvmConfig { type ExecutionCtx<'a> = EthBlockExecutionCtx<'a>; type Transaction = TransactionSigned; type Receipt = Receipt; + type TxExecutionResult = crate::executor::ArcTxResult< + HaltReason, + ::TxType, + >; + type Executor<'a, DB: StateDB, I: Inspector>> = ArcBlockExecutor< + 'a, + ::Evm, + &'a Arc, + &'a reth_ethereum::evm::RethReceiptBuilder, + >; fn evm_factory(&self) -> &Self::EvmFactory { &self.evm_factory_instance @@ -1783,12 +1800,12 @@ impl BlockExecutorFactory for ArcEvmConfig { fn create_executor<'a, DB, I>( &'a self, - evm: ::Evm<&'a mut State, I>, + evm: ::Evm, ctx: EthBlockExecutionCtx<'a>, - ) -> impl BlockExecutorFor<'a, Self, DB, I> + ) -> Self::Executor<'a, DB, I> where - DB: Database + 'a, - I: InspectorFor> + 'a, + DB: StateDB, + I: Inspector<::Context>, { ArcBlockExecutor::new( evm, @@ -1824,10 +1841,7 @@ impl ConfigureEvm for ArcEvmConfig { parent: &'a SealedHeader<::BlockHeader>, attributes: Self::NextBlockEnvCtx, ) -> Result< - impl BlockBuilder< - Primitives = Self::Primitives, - Executor: BlockExecutorFor<'a, Self::BlockExecutorFactory, DB>, - >, + impl BlockBuilder>, Self::Error, > { // Query the ProtocolConfig contract for the reward beneficiary using system call @@ -1914,17 +1928,14 @@ mod tests { use super::*; use crate::frame_result::BeforeFrameInitResult; - use crate::log::NativeCoinTransferred; use crate::log::Transfer; use alloy_consensus::Block; use alloy_primitives::{address, Bytes, B256, U256}; use alloy_rpc_types_engine::ExecutionData; use alloy_sol_types::SolEvent; - use arc_execution_config::chainspec::{DEVNET, LOCAL_DEV, TESTNET}; + use arc_execution_config::chainspec::{localdev_with_hardforks, DEVNET, LOCAL_DEV, TESTNET}; use arc_precompiles::precompile_provider::ArcPrecompileProvider; - use arc_precompiles::{ - native_coin_control, NATIVE_COIN_AUTHORITY_ADDRESS, NATIVE_COIN_CONTROL_ADDRESS, - }; + use arc_precompiles::{native_coin_control, NATIVE_COIN_CONTROL_ADDRESS}; use reth_chainspec::{EthChainSpec, ForkCondition}; use reth_ethereum::evm::revm::{ context::CfgEnv, @@ -1955,12 +1966,6 @@ mod tests { use revm_primitives::hardfork::SpecId; use rstest::rstest; - struct TestCase { - name: &'static str, - frame_input: FrameInput, - expected_log: Option, - } - const ADDRESS_A: Address = address!("1000000000000000000000000000000000000001"); const ADDRESS_B: Address = address!("2000000000000000000000000000000000000002"); @@ -1998,22 +2003,7 @@ mod tests { let precompiles = ArcPrecompileProvider::create_precompiles_map(spec, hardfork_flags); let inspector = NoOpInspector {}; - if hardfork_flags.is_active(ArcHardfork::Zero7) { - instruction.insert_instruction( - SELFDESTRUCT, - revm_interpreter::Instruction::new(arc_network_selfdestruct_zero7, 5000), - ); - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - instruction.insert_instruction( - SELFDESTRUCT, - revm_interpreter::Instruction::new(arc_network_selfdestruct_zero5, 5000), - ); - } else { - instruction.insert_instruction( - SELFDESTRUCT, - revm_interpreter::Instruction::new(arc_network_selfdestruct_zero4, 5000), - ); - } + insert_arc_selfdestruct_instruction(&mut instruction, hardfork_flags); let factory = ArcEvmFactory::new(chain_spec); let subcall_registry = factory.build_subcall_registry(hardfork_flags); @@ -2081,6 +2071,21 @@ mod tests { db } + fn pre_zero7_chain_spec() -> Arc { + localdev_with_hardforks(&[ + (ArcHardfork::Zero3, ForkCondition::Block(0)), + (ArcHardfork::Zero4, ForkCondition::Block(0)), + (ArcHardfork::Zero5, ForkCondition::Block(0)), + (ArcHardfork::Zero6, ForkCondition::Block(0)), + ]) + } + + fn mark_blocklisted(db: &mut InMemoryDB, address: Address) { + let slot = native_coin_control::compute_is_blocklisted_storage_slot(address); + db.insert_account_storage(NATIVE_COIN_CONTROL_ADDRESS, slot.into(), U256::from(1)) + .expect("insert blocklist storage"); + } + fn call_input( scheme: CallScheme, value: U256, @@ -2091,13 +2096,17 @@ mod tests { scheme, target_address: target, bytecode_address: address!("2000000000000000000000000000000000000002"), - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(value), input: CallInput::Bytes(Bytes::new()), gas_limit: 100_000, is_static: false, caller, return_memory_offset: 0..1, + reservoir: 0, }) } @@ -2108,6 +2117,7 @@ mod tests { value, Bytes::new(), 100_000, + 0, )) } @@ -2157,6 +2167,13 @@ mod tests { Bytecode::new_legacy(bytecode.into()) } + fn selfdestruct_to_bytecode(target: Address) -> Bytecode { + let mut runtime = vec![opcode::PUSH20]; + runtime.extend_from_slice(target.as_slice()); + runtime.push(SELFDESTRUCT); + Bytecode::new_raw(Bytes::from(runtime)) + } + /// Builds bytecode that copies `init_code` into memory then executes /// `CREATE(value, 0, init_code.len())`. /// @@ -2276,6 +2293,7 @@ mod tests { parent_beacon_block_root: None, withdrawals: None, extra_data: Default::default(), + slot_number: None, }; let result = evm_config.builder_for_next_block(&mut db, &sealed_parent, attributes); @@ -2286,7 +2304,7 @@ mod tests { } #[test] - fn test_system_call_preserves_system_call_semantics() { + fn test_system_call_preserves_basic_system_call_semantics() { let chain_spec = LOCAL_DEV.clone(); let system_contract = Address::repeat_byte(0x21); let return_value = U256::from(42); @@ -2338,23 +2356,21 @@ mod tests { let chain_spec = LOCAL_DEV.clone(); let system_contract = Address::repeat_byte(0x21); let blocklisted_target = Address::repeat_byte(0x22); - let transfer_amount = U256::from(1); + let transfer_amount = U256::from(7); let runtime = call_with_value_bytecode(blocklisted_target, transfer_amount); let mut db = create_db(&[]); db.insert_account_info( system_contract, revm::state::AccountInfo { - balance: U256::from(10), + balance: transfer_amount, nonce: 1, code_hash: keccak256(runtime.bytecode()), code: Some(runtime), account_id: None, }, ); - let slot = native_coin_control::compute_is_blocklisted_storage_slot(blocklisted_target); - db.insert_account_storage(NATIVE_COIN_CONTROL_ADDRESS, slot.into(), U256::from(1)) - .expect("insert blocklist storage"); + mark_blocklisted(&mut db, blocklisted_target); let mut evm = create_arc_evm(chain_spec, db); let result = alloy_evm::Evm::transact_system_call( @@ -2388,35 +2404,24 @@ mod tests { } #[test] - fn test_pre_zero7_system_call_preserves_nested_call_blocklist_bypass() { - use arc_execution_config::{chainspec::localdev_with_hardforks, hardforks::ArcHardfork}; - use reth_chainspec::ForkCondition; - - let chain_spec = localdev_with_hardforks(&[ - (ArcHardfork::Zero3, ForkCondition::Block(0)), - (ArcHardfork::Zero4, ForkCondition::Block(0)), - (ArcHardfork::Zero5, ForkCondition::Block(0)), - (ArcHardfork::Zero6, ForkCondition::Block(0)), - ]); + fn test_system_call_preloads_native_coin_control_for_selfdestruct_blocklist() { + let chain_spec = LOCAL_DEV.clone(); let system_contract = Address::repeat_byte(0x21); let blocklisted_target = Address::repeat_byte(0x22); - let transfer_amount = U256::from(1); - let runtime = call_with_value_bytecode(blocklisted_target, transfer_amount); + let runtime = selfdestruct_to_bytecode(blocklisted_target); let mut db = create_db(&[]); db.insert_account_info( system_contract, revm::state::AccountInfo { - balance: U256::from(10), + balance: U256::from(1), nonce: 1, code_hash: keccak256(runtime.bytecode()), code: Some(runtime), account_id: None, }, ); - let slot = native_coin_control::compute_is_blocklisted_storage_slot(blocklisted_target); - db.insert_account_storage(NATIVE_COIN_CONTROL_ADDRESS, slot.into(), U256::from(1)) - .expect("insert blocklist storage"); + mark_blocklisted(&mut db, blocklisted_target); let mut evm = create_arc_evm(chain_spec, db); let result = alloy_evm::Evm::transact_system_call( @@ -2427,50 +2432,37 @@ mod tests { ) .expect("system call should execute"); - assert!( - result.result.is_success(), - "pre-Zero7 parent system call should succeed" - ); - - let target_balance = result - .state - .get(&blocklisted_target) - .map(|account| account.info.balance) - .unwrap_or(U256::ZERO); + let ExecutionResult::Revert { output, .. } = result.result else { + panic!("system call selfdestruct to blocklisted target should revert"); + }; assert_eq!( - target_balance, transfer_amount, - "pre-Zero7 nested CALL to blocklisted target should preserve old blocklist bypass" + output, + arc_precompiles::helpers::revert_message_to_bytes(ERR_BLOCKED_ADDRESS), ); } #[test] - fn test_system_call_preloads_native_coin_control_for_selfdestruct_blocklist() { - let chain_spec = LOCAL_DEV.clone(); + fn test_pre_zero7_system_call_preserves_nested_call_blocklist_bypass() { + let chain_spec = pre_zero7_chain_spec(); let system_contract = Address::repeat_byte(0x21); let blocklisted_target = Address::repeat_byte(0x22); - - // PUSH20 SELFDESTRUCT - let mut runtime = vec![opcode::PUSH20]; - runtime.extend_from_slice(blocklisted_target.as_slice()); - runtime.push(SELFDESTRUCT); - let runtime = Bytecode::new_raw(Bytes::from(runtime)); + let transfer_amount = U256::from(7); + let runtime = call_with_value_bytecode(blocklisted_target, transfer_amount); let mut db = create_db(&[]); db.insert_account_info( system_contract, revm::state::AccountInfo { - balance: U256::from(1), + balance: transfer_amount, nonce: 1, code_hash: keccak256(runtime.bytecode()), code: Some(runtime), account_id: None, }, ); - let slot = native_coin_control::compute_is_blocklisted_storage_slot(blocklisted_target); - db.insert_account_storage(NATIVE_COIN_CONTROL_ADDRESS, slot.into(), U256::from(1)) - .expect("insert blocklist storage"); - let mut evm = create_arc_evm(chain_spec, db); + mark_blocklisted(&mut db, blocklisted_target); + let mut evm = create_arc_evm(chain_spec, db); let result = alloy_evm::Evm::transact_system_call( &mut evm, Address::ZERO, @@ -2479,34 +2471,24 @@ mod tests { ) .expect("system call should execute"); - let ExecutionResult::Revert { output, .. } = result.result else { - panic!("system call selfdestruct to blocklisted target should revert"); - }; - assert_eq!( - output, - arc_precompiles::helpers::revert_message_to_bytes(ERR_BLOCKED_ADDRESS), + assert!( + result.result.is_success(), + "pre-Zero7 system-call nested CALL should bypass Arc frame blocklist hooks" ); + let target_balance = result + .state + .get(&blocklisted_target) + .map(|account| account.info.balance) + .unwrap_or(U256::ZERO); + assert_eq!(target_balance, transfer_amount); } #[test] fn test_pre_zero7_system_call_preserves_selfdestruct_blocklist_fail_open() { - use arc_execution_config::{chainspec::localdev_with_hardforks, hardforks::ArcHardfork}; - use reth_chainspec::ForkCondition; - - let chain_spec = localdev_with_hardforks(&[ - (ArcHardfork::Zero3, ForkCondition::Block(0)), - (ArcHardfork::Zero4, ForkCondition::Block(0)), - (ArcHardfork::Zero5, ForkCondition::Block(0)), - (ArcHardfork::Zero6, ForkCondition::Block(0)), - ]); + let chain_spec = pre_zero7_chain_spec(); let system_contract = Address::repeat_byte(0x21); let blocklisted_target = Address::repeat_byte(0x22); - - // PUSH20 SELFDESTRUCT - let mut runtime = vec![opcode::PUSH20]; - runtime.extend_from_slice(blocklisted_target.as_slice()); - runtime.push(SELFDESTRUCT); - let runtime = Bytecode::new_raw(Bytes::from(runtime)); + let runtime = selfdestruct_to_bytecode(blocklisted_target); let mut db = create_db(&[]); db.insert_account_info( @@ -2519,9 +2501,7 @@ mod tests { account_id: None, }, ); - let slot = native_coin_control::compute_is_blocklisted_storage_slot(blocklisted_target); - db.insert_account_storage(NATIVE_COIN_CONTROL_ADDRESS, slot.into(), U256::from(1)) - .expect("insert blocklist storage"); + mark_blocklisted(&mut db, blocklisted_target); let mut evm = create_arc_evm(chain_spec, db); let result = alloy_evm::Evm::transact_system_call( @@ -2534,7 +2514,7 @@ mod tests { assert!( result.result.is_success(), - "pre-Zero7 system call should preserve old fail-open behavior" + "pre-Zero7 system call should preserve old fail-open SELFDESTRUCT behavior" ); } @@ -2542,7 +2522,6 @@ mod tests { #[test] fn test_transact_one_eip7708_log_under_zero5() { use alloy_primitives::B256; - use arc_execution_config::{chainspec::localdev_with_hardforks, hardforks::ArcHardfork}; use revm::handler::SYSTEM_ADDRESS; use revm_primitives::TxKind; @@ -2670,11 +2649,11 @@ mod tests { ); } - /// Zero-value CALL under Zero5 emits no log (transfer amount check short-circuits). + /// Zero-value CALL emits no log (transfer amount check short-circuits). #[test] - fn test_zero5_zero_value_call_no_log() { + fn test_baseline_zero_value_call_no_log() { let db = CacheDB::new(EmptyDB::default()); - let flags = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); + let flags = ArcHardforkFlags::default(); let mut evm = create_test_evm(db, flags); evm.ctx_mut() @@ -2696,152 +2675,10 @@ mod tests { let result = evm.before_frame_init(&mut frame).unwrap(); assert!( matches!(result, BeforeFrameInitResult::None), - "Zero-value call under Zero5 should return None (no log, no blocklist checks)" + "zero-value call should return None (no log, no blocklist checks)" ); } - #[test] - fn test_capture_transfer_events() { - let test_cases = vec![ - TestCase { - name: "call with value", - frame_input: FrameInput::Call(call_input( - CallScheme::Call, - U256::from(1), - ADDRESS_A, - ADDRESS_B, - )), - expected_log: Some(NativeCoinTransferred { - from: ADDRESS_A, - to: ADDRESS_B, - amount: U256::from(1), - }), - }, - TestCase { - name: "call with no value", - frame_input: FrameInput::Call(call_input( - CallScheme::Call, - U256::ZERO, - ADDRESS_A, - ADDRESS_B, - )), - expected_log: None, - }, - TestCase { - name: "create with value", - frame_input: FrameInput::Create(create_input( - CreateScheme::Create, - U256::from(1), - ADDRESS_A, - )), - expected_log: Some(NativeCoinTransferred { - from: ADDRESS_A, - to: ADDRESS_A.create(0), - amount: U256::from(1), - }), - }, - TestCase { - name: "create with no value", - frame_input: FrameInput::Create(create_input( - CreateScheme::Create, - U256::ZERO, - ADDRESS_A, - )), - expected_log: None, - }, - TestCase { - name: "create2 with value", - frame_input: FrameInput::Create(create_input( - CreateScheme::Create2 { - salt: U256::from(123), - }, - U256::from(1), - ADDRESS_A, - )), - expected_log: Some(NativeCoinTransferred { - from: ADDRESS_A, - to: ADDRESS_A.create2(U256::from(123).to_be_bytes(), keccak256(Bytes::new())), - amount: U256::from(1), - }), - }, - TestCase { - name: "create2 with no value", - frame_input: FrameInput::Create(create_input( - CreateScheme::Create2 { - salt: U256::from(123), - }, - U256::ZERO, - ADDRESS_A, - )), - expected_log: None, - }, - ]; - - // Test pre-Zero5 hardforks only — Zero5 enables EIP-7708 which emits different logs. - for hardfork in [ArcHardfork::Zero3, ArcHardfork::Zero4] { - for test in &test_cases { - let mut frame = FrameInit { - frame_input: test.frame_input.clone(), - memory: SharedMemory::default(), - depth: 0, - }; - - let db = CacheDB::new(EmptyDB::default()); - let mut evm = create_test_evm(db, ArcHardforkFlags::with(&[hardfork])); - - // Load native coin control account - evm.ctx_mut() - .journal_mut() - .load_account(NATIVE_COIN_CONTROL_ADDRESS) - .unwrap(); - - let transfer_result = evm.before_frame_init(&mut frame).unwrap(); - - // No early return should occur for basic tests - assert!( - !matches!(transfer_result, BeforeFrameInitResult::Reverted(_)), - "{} (hardfork: {:?}): unexpected blocklist violation", - test.name, - hardfork - ); - - let log_opt = match transfer_result { - BeforeFrameInitResult::Log(log, _gas) => Some(log), - _ => None, - }; - - assert_eq!( - log_opt.is_some(), - test.expected_log.is_some(), - "{} (hardfork: {:?}): unexpected log result", - test.name, - hardfork - ); - - if let Some(log) = log_opt { - assert_eq!( - log.address, NATIVE_COIN_AUTHORITY_ADDRESS, - "{} (hardfork: {:?}): wrong log address", - test.name, hardfork - ); - - let log_data = NativeCoinTransferred::decode_log(&log); - assert!( - log_data.is_ok(), - "{} (hardfork: {:?}): failed to decode log", - test.name, - hardfork - ); - assert_eq!( - &log_data.unwrap().data, - test.expected_log.as_ref().unwrap(), - "Native send event mismatch" - ); - } - } - } - } - #[test] fn test_create2_with_value_normalizes_to_custom_address() { let db = CacheDB::new(EmptyDB::default()); @@ -2861,6 +2698,7 @@ mod tests { U256::from(1), init_code, 100_000, + 0, ))), memory: SharedMemory::default(), depth: 0, @@ -2914,6 +2752,7 @@ mod tests { U256::from(1), init_code, 100_000, + 0, ))), memory: SharedMemory::default(), depth: 0, @@ -3030,7 +2869,7 @@ mod tests { ); if let BeforeFrameInitResult::Reverted(reverted) = transfer_result { assert_eq!( - reverted.gas().spent(), + reverted.gas().total_gas_spent(), 0, "{} (hardfork: {:?}): depth-0 reverts should have zero gas spent", test_case.name, @@ -3260,70 +3099,6 @@ mod tests { ); } - /// Verifies that `create_arc_evm` dispatches the pre-Zero5 SELFDESTRUCT variant when - /// only Zero3+Zero4 are active. Pre-Zero5 allows SELFDESTRUCT to the zero address - /// (unlike Zero5) and emits `NativeCoinTransferred` (unlike EIP-7708). - #[test] - fn create_arc_evm_dispatches_selfdestruct_zero4() { - use arc_execution_config::{chainspec::localdev_with_hardforks, hardforks::ArcHardfork}; - use revm_primitives::TxKind; - - let chain_spec = localdev_with_hardforks(&[ - (ArcHardfork::Zero3, ForkCondition::Block(0)), - (ArcHardfork::Zero4, ForkCondition::Block(0)), - ]); - let sender = Address::repeat_byte(0x11); - let contract = Address::repeat_byte(0xBB); - - // Bytecode: PUSH20 0x00..00 SELFDESTRUCT (target = zero address) - let mut code = vec![opcode::PUSH20]; - code.extend_from_slice(Address::ZERO.as_slice()); - code.push(SELFDESTRUCT); - let runtime = Bytecode::new_raw(Bytes::from(code.clone())); - - let mut db = create_db(&[(sender, 1000)]); - db.insert_account_info( - contract, - revm::state::AccountInfo { - balance: U256::from(500), - nonce: 1, - code_hash: keccak256(&code), - code: Some(runtime), - account_id: None, - }, - ); - - let mut evm = create_arc_evm(chain_spec.clone(), db); - let tx = TxEnv { - caller: sender, - kind: TxKind::Call(contract), - value: U256::ZERO, - gas_limit: 100_000, - gas_price: 0, - chain_id: Some(chain_spec.chain_id()), - ..Default::default() - }; - - let result = evm.transact_one(tx).expect("transaction should execute"); - // Zero4 allows SELFDESTRUCT to the zero address — not a revert. - assert!( - result.is_success(), - "Zero4 SELFDESTRUCT to zero address should succeed, got {:?}", - result - ); - // Zero4 emits the custom NativeCoinTransferred log, not EIP-7708. - let logs = result.logs(); - assert!( - !logs.is_empty(), - "Zero4 SELFDESTRUCT with nonzero balance should emit a transfer log" - ); - assert_ne!( - logs[0].address, - revm::handler::SYSTEM_ADDRESS, - "Zero4 should emit NativeCoinTransferred, not an EIP-7708 Transfer log" - ); - } - /// Transaction-level regression test: a Zero5 SELFDESTRUCT with nonzero balance /// must produce exactly one EIP-7708 Transfer log in the final receipt. #[test] @@ -3444,13 +3219,17 @@ mod tests { scheme: CallScheme::Call, target_address: ADDRESS_B, bytecode_address: ADDRESS_A, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::from(100)), input: CallInput::Bytes(Bytes::new()), gas_limit: 100_000, caller: ADDRESS_A, is_static: false, return_memory_offset: 0..0, + reservoir: 0, })), memory: SharedMemory::default(), depth: 0, @@ -3560,6 +3339,7 @@ mod tests { amount, Bytes::from(vec![0x60, 0x00, 0x60, 0x00, 0xF3]), // dummy contract byte code 100_000, + 0, ))), memory: SharedMemory::default(), depth: 0, @@ -3579,7 +3359,7 @@ mod tests { assert_eq!( is_account_cold(&evm, target_address), expected_target_is_cold, - "unexpected target warmth after selfdestruct probe for hardforks {hardforks:?}" + "unexpected create target warmth after selfdestruct probe for hardforks {hardforks:?}" ); } @@ -3656,6 +3436,7 @@ mod tests { parent_beacon_block_root: None, withdrawals: None, extra_data: Default::default(), + slot_number: None, }; let ctx = evm_config @@ -4006,17 +3787,7 @@ mod tests { .with_block(BlockEnv::default()); let precompiles = ArcPrecompileProvider::create_precompiles_map(spec, hardfork_flags); let mut instruction = EthInstructions::new_mainnet_with_spec(spec); - if hardfork_flags.is_active(ArcHardfork::Zero7) { - instruction.insert_instruction( - SELFDESTRUCT, - revm_interpreter::Instruction::new(arc_network_selfdestruct_zero7, 5000), - ); - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - instruction.insert_instruction( - SELFDESTRUCT, - revm_interpreter::Instruction::new(arc_network_selfdestruct_zero5, 5000), - ); - } + insert_arc_selfdestruct_instruction(&mut instruction, hardfork_flags); let mut registry = SubcallRegistry::new(); registry.register( @@ -4044,9 +3815,7 @@ mod tests { } fn insert_eip7702_account(evm: &mut NoOpTestEvm, address: Address, delegate: Address) { - use revm::bytecode::eip7702::Eip7702Bytecode; - - let eip7702_code = Bytecode::Eip7702(Arc::new(Eip7702Bytecode::new(delegate))); + let eip7702_code = Bytecode::new_eip7702(delegate); evm.inner.ctx.journal_mut().db_mut().insert_account_info( address, AccountInfo { @@ -4367,17 +4136,7 @@ mod tests { .with_block(BlockEnv::default()); let precompiles = ArcPrecompileProvider::create_precompiles_map(spec, hardfork_flags); let mut instruction = EthInstructions::new_mainnet_with_spec(spec); - if hardfork_flags.is_active(ArcHardfork::Zero7) { - instruction.insert_instruction( - SELFDESTRUCT, - revm_interpreter::Instruction::new(arc_network_selfdestruct_zero7, 5000), - ); - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - instruction.insert_instruction( - SELFDESTRUCT, - revm_interpreter::Instruction::new(arc_network_selfdestruct_zero5, 5000), - ); - } + insert_arc_selfdestruct_instruction(&mut instruction, hardfork_flags); // Custom registry: restrict subcall_test to `authorized_caller` only let mut registry = SubcallRegistry::new(); @@ -4632,7 +4391,7 @@ mod tests { .transact_one(tx_echo) .expect("transact_one should succeed"); let gas_used_echo = match &result_echo { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -4655,7 +4414,7 @@ mod tests { .transact_one(tx_burner) .expect("transact_one should succeed"); let gas_used_burner = match &result_burner { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -4906,9 +4665,7 @@ mod tests { let result = evm.transact_one(tx).expect("transact_one should succeed"); let (output, gas_used) = match &result { - ExecutionResult::Success { - output, gas_used, .. - } => (output, *gas_used), + ExecutionResult::Success { output, gas, .. } => (output, gas.total_gas_spent()), other => panic!("expected Success (wrapper catches revert), got {other:?}"), }; @@ -5393,7 +5150,7 @@ mod tests { }; let result_callfrom = evm.transact_one(tx_callfrom).expect("callfrom tx"); let gas_used_callfrom = match &result_callfrom { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success (wrapper catches OOG), got {other:?}"), }; @@ -5411,7 +5168,7 @@ mod tests { }; let result_direct = evm.transact_one(tx_direct).expect("direct tx"); let gas_used_direct = match &result_direct { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -5597,17 +5354,7 @@ mod tests { .with_block(BlockEnv::default()); let precompiles = ArcPrecompileProvider::create_precompiles_map(spec, hardfork_flags); let mut instruction = EthInstructions::new_mainnet_with_spec(spec); - if hardfork_flags.is_active(ArcHardfork::Zero7) { - instruction.insert_instruction( - SELFDESTRUCT, - revm_interpreter::Instruction::new(arc_network_selfdestruct_zero7, 5000), - ); - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - instruction.insert_instruction( - SELFDESTRUCT, - revm_interpreter::Instruction::new(arc_network_selfdestruct_zero5, 5000), - ); - } + insert_arc_selfdestruct_instruction(&mut instruction, hardfork_flags); let mut registry = SubcallRegistry::new(); registry.register( @@ -5703,7 +5450,7 @@ mod tests { .inspect_one_tx(tx) .expect("inspect_one_tx should succeed"); let gas_used = match &result { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -5801,7 +5548,7 @@ mod tests { .inspect_one_tx(tx) .expect("inspect_one_tx should succeed"); let gas_used = match &result { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -5877,7 +5624,7 @@ mod tests { .inspect_one_tx(tx) .expect("inspect_one_tx should succeed"); let gas_used = match &result { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -5941,13 +5688,17 @@ mod tests { scheme: CallScheme::Call, target_address: CALL_FROM_ADDRESS, bytecode_address: CALL_FROM_ADDRESS, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(Bytes::from(calldata)), gas_limit, is_static: false, caller: WRAPPER, return_memory_offset: 0..0, + reservoir: 0, }; let result = precompile @@ -5986,13 +5737,17 @@ mod tests { scheme: CallScheme::Call, target_address: CALL_FROM_ADDRESS, bytecode_address: CALL_FROM_ADDRESS, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(Bytes::from(calldata)), gas_limit: abi_decode_gas(child_data.len()) - 1, // Not enough is_static: false, caller: WRAPPER, return_memory_offset: 0..0, + reservoir: 0, }; let result = precompile.init_subcall(&inputs); @@ -6060,7 +5815,7 @@ mod tests { }; let result_success = evm.transact_one(tx_success).expect("success tx"); let gas_used_success = match &result_success { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.tx_gas_used(), other => panic!("expected Success, got {other:?}"), }; @@ -6079,7 +5834,7 @@ mod tests { }; let result_revert = evm.transact_one(tx_revert).expect("revert tx"); let gas_used_revert = match &result_revert { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.tx_gas_used(), other => panic!("expected Success (wrapper catches revert), got {other:?}"), }; @@ -6293,10 +6048,11 @@ mod tests { scheme: CallScheme::Call, target_address: CALL_FROM_ADDRESS, bytecode_address: CALL_FROM_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(Bytes::from(calldata)), gas_limit, + reservoir: 0, is_static: false, caller: WRAPPER, return_memory_offset: 0..0, @@ -6318,7 +6074,7 @@ mod tests { ItemOrResult::Result(FrameResult::Call(outcome)) => { assert_eq!(outcome.result.result, InstructionResult::OutOfGas); assert_eq!( - outcome.result.gas.spent(), + outcome.result.gas.total_gas_spent(), gas_limit, "OOG should consume the whole subcall gas budget" ); @@ -6490,7 +6246,7 @@ mod tests { let result_failing = evm.transact_one(tx_failing).expect("tx should succeed"); let gas_used_failing = match &result_failing { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success (wrapper catches revert), got {other:?}"), }; @@ -6509,7 +6265,7 @@ mod tests { let result_normal = evm.transact_one(tx_normal).expect("tx should succeed"); let gas_used_normal = match &result_normal { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -6843,9 +6599,14 @@ mod tests { let result = evm.transact_one(tx).expect("tx should execute"); match result { - ExecutionResult::Revert { gas_used, output } => { + ExecutionResult::Revert { + gas, + output, + logs: _, + } => { assert_eq!( - gas_used, gas_limit, + gas.total_gas_spent(), + gas_limit, "halted child with fitting completion gas should burn the full subcall allocation" ); assert!( @@ -6904,7 +6665,7 @@ mod tests { evm.inner.ctx.journal_mut().checkpoint_commit(); let mut child_gas = Gas::new(10); - assert!(child_gas.record_cost(child_gas_spent)); + assert!(child_gas.record_regular_cost(child_gas_spent)); let child_result = FrameResult::Call(CallOutcome { result: InterpreterResult::new( child_instruction_result, @@ -6946,7 +6707,7 @@ mod tests { "exact gas fit should not OOG" ); assert_eq!( - exact_outcome.result.gas.spent(), + exact_outcome.result.gas.total_gas_spent(), 12, "exact gas fit should spend the metered gas" ); @@ -6962,7 +6723,7 @@ mod tests { "one gas below metered cost should OOG" ); assert_eq!( - oog_outcome.result.gas.spent(), + oog_outcome.result.gas.total_gas_spent(), 11, "OOG should spend the full subcall gas limit" ); @@ -6978,7 +6739,7 @@ mod tests { "halted child with exact gas fit should not OOG" ); assert_eq!( - halted_exact_outcome.result.gas.spent(), + halted_exact_outcome.result.gas.total_gas_spent(), 18, "halted exact gas fit should spend the metered gas" ); @@ -6994,7 +6755,7 @@ mod tests { "halted child one gas below metered cost should OOG" ); assert_eq!( - halted_oog_outcome.result.gas.spent(), + halted_oog_outcome.result.gas.total_gas_spent(), 17, "halted OOG should spend the full subcall gas limit" ); @@ -7318,7 +7079,7 @@ mod tests { }); let result = evm.replay().expect("replay should succeed"); assert!(result.result.is_success(), "double wrapper should succeed"); - result.result.gas_used() + result.result.tx_gas_used() }; // Both-cold: target A then target B (different addresses, both cold) @@ -7441,7 +7202,7 @@ mod tests { let result_zero = evm.transact_one(tx_zero).expect("tx should succeed"); let gas_used_zero = match &result_zero { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -7461,7 +7222,7 @@ mod tests { let result_costly = evm.transact_one(tx_costly).expect("tx should succeed"); let gas_used_costly = match &result_costly { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -7574,7 +7335,7 @@ mod tests { let result_zero = evm.transact_one(tx_zero).expect("tx should succeed"); let gas_used_zero = match &result_zero { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success (wrapper catches revert), got {other:?}"), }; @@ -7594,7 +7355,7 @@ mod tests { let result_costly = evm.transact_one(tx_costly).expect("tx should succeed"); let gas_used_costly = match &result_costly { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success (wrapper catches revert), got {other:?}"), }; @@ -7708,7 +7469,7 @@ mod tests { .transact_one(tx_cold) .expect("cold tx should succeed"); let gas_cold = match &result_cold { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -7740,7 +7501,7 @@ mod tests { .transact_one(tx_warm) .expect("warm tx should succeed"); let gas_warm = match &result_warm { - ExecutionResult::Success { gas_used, .. } => *gas_used, + ExecutionResult::Success { gas, .. } => gas.total_gas_spent(), other => panic!("expected Success, got {other:?}"), }; @@ -7796,13 +7557,17 @@ mod tests { scheme: CallScheme::Call, target_address: CALL_FROM_ADDRESS, bytecode_address: CALL_FROM_ADDRESS, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(Bytes::from(calldata)), gas_limit, is_static: false, caller: WRAPPER, return_memory_offset: 0..0, + reservoir: 0, }; let frame_input = FrameInit { @@ -7883,13 +7648,17 @@ mod tests { scheme: CallScheme::Call, target_address: CALL_FROM_ADDRESS, bytecode_address: CALL_FROM_ADDRESS, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(Bytes::from(calldata)), gas_limit, is_static: false, caller: WRAPPER, return_memory_offset: 0..0, + reservoir: 0, }; let frame_input = FrameInit { @@ -7964,13 +7733,17 @@ mod tests { scheme: CallScheme::Call, target_address: CALL_FROM_ADDRESS, bytecode_address: CALL_FROM_ADDRESS, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(Bytes::from(calldata)), gas_limit, is_static: false, caller: WRAPPER, return_memory_offset: 0..0, + reservoir: 0, }; let frame_input = FrameInit { @@ -8045,13 +7818,17 @@ mod tests { scheme: CallScheme::Call, target_address: CALL_FROM_ADDRESS, bytecode_address: CALL_FROM_ADDRESS, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(Bytes::from(calldata)), gas_limit: insufficient_gas, is_static: false, caller: WRAPPER, return_memory_offset: 0..0, + reservoir: 0, }; let frame_input = FrameInit { @@ -8074,7 +7851,7 @@ mod tests { "should OOG when gas is insufficient for account access cost" ); assert_eq!( - outcome.result.gas.spent(), + outcome.result.gas.total_gas_spent(), insufficient_gas, "OOG should consume all allocated gas" ); @@ -8136,13 +7913,17 @@ mod tests { scheme: CallScheme::Call, target_address: CALL_FROM_ADDRESS, bytecode_address: CALL_FROM_ADDRESS, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(Bytes::from(calldata)), gas_limit: exact_gas, is_static: false, caller: WRAPPER, return_memory_offset: 0..0, + reservoir: 0, }; let frame_input = FrameInit { @@ -8182,7 +7963,8 @@ mod tests { EthInstructions>, PrecompilesMap, > { - let ctx = Context::new(db, spec); + let mut ctx = Context::new(db, spec); + ctx.cfg.amsterdam_eip7708_disabled = true; let instruction = EthInstructions::new_mainnet_with_spec(spec); let precompiles = ArcPrecompileProvider::create_precompiles_map(spec, hardfork_flags); ArcEvm::new( @@ -8197,11 +7979,9 @@ mod tests { } #[test] - fn test_zero5_emits_eip7708_transfer_log() { - use revm::handler::SYSTEM_ADDRESS; - + fn test_baseline_emits_eip7708_transfer_log() { let db = CacheDB::new(EmptyDB::default()); - let flags = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); + let flags = ArcHardforkFlags::default(); let mut evm = create_test_evm(db, flags); evm.ctx_mut() @@ -8220,26 +8000,28 @@ mod tests { depth: 1, }; + let amount = U256::from(100); let result = evm.before_frame_init(&mut frame).unwrap(); match result { BeforeFrameInitResult::Log(log, gas) => { assert!(gas > 0, "Should have SLOAD gas cost"); assert_eq!( - log.address, SYSTEM_ADDRESS, - "Zero5 should emit EIP-7708 Transfer log from system address" + log, + create_eip7708_transfer_log(ADDRESS_A, ADDRESS_B, amount), + "baseline should emit EIP-7708 Transfer log from system address" ); } other => panic!( - "Expected Log result with EIP-7708 Transfer under Zero5, got {:?}", + "Expected Log result with EIP-7708 Transfer under baseline flags, got {:?}", other ), } } #[test] - fn test_zero5_self_transfer_no_log() { + fn test_baseline_self_transfer_no_log() { let db = CacheDB::new(EmptyDB::default()); - let flags = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); + let flags = ArcHardforkFlags::default(); let mut evm = create_test_evm(db, flags); evm.ctx_mut() @@ -8265,43 +8047,12 @@ mod tests { assert!(gas > 0, "Should have SLOAD gas cost"); } other => panic!( - "Expected Checked result for self-transfer under Zero5, got {:?}", + "Expected Checked result for self-transfer under baseline flags, got {:?}", other ), } } - #[test] - fn test_pre_zero5_still_emits_custom_log() { - let db = CacheDB::new(EmptyDB::default()); - let flags = ArcHardforkFlags::with(&[ArcHardfork::Zero4]); - let mut evm = create_test_evm(db, flags); - - evm.ctx_mut() - .journal_mut() - .load_account(NATIVE_COIN_CONTROL_ADDRESS) - .unwrap(); - - let mut frame = FrameInit { - frame_input: FrameInput::Call(call_input( - CallScheme::Call, - U256::from(100), - ADDRESS_A, - ADDRESS_B, - )), - memory: SharedMemory::default(), - depth: 1, - }; - - let result = evm.before_frame_init(&mut frame).unwrap(); - match result { - BeforeFrameInitResult::Log(log, _gas) => { - assert_eq!(log.address, NATIVE_COIN_AUTHORITY_ADDRESS); - } - other => panic!("Expected Log result pre-Zero5, got {:?}", other), - } - } - /// Verifies that AMSTERDAM SpecId enables EIP-7708 (is_enabled_in returns true). /// Once REVM is upgraded to a version with EIP-7708 journal support, the journal's /// `transfer` method will emit Transfer logs when SpecId >= AMSTERDAM. @@ -8319,7 +8070,7 @@ mod tests { // Verify that an EVM can be created with AMSTERDAM spec (for future use) let db = create_db(&[(ADDRESS_A, 1000)]); - let flags = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); + let flags = ArcHardforkFlags::default(); let evm = create_test_evm_with_spec(db, flags, SpecId::AMSTERDAM); assert_eq!( evm.inner.ctx.cfg.spec, @@ -8328,15 +8079,12 @@ mod tests { ); } - /// Verifies that Zero5 emits EIP-7708 Transfer logs regardless of SpecId. + /// Verifies that the baseline emits EIP-7708 Transfer logs regardless of SpecId. /// Arc self-implements EIP-7708 log emission, so PRAGUE vs AMSTERDAM doesn't matter. #[test] - fn test_zero5_emits_eip7708_regardless_of_spec() { - use revm::handler::SYSTEM_ADDRESS; - - // Zero5 + PRAGUE: Arc emits EIP-7708 Transfer logs itself + fn test_baseline_emits_eip7708_regardless_of_spec() { let db = CacheDB::new(EmptyDB::default()); - let flags = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); + let flags = ArcHardforkFlags::default(); let mut evm = create_test_evm_with_spec(db, flags, SpecId::PRAGUE); evm.ctx_mut() @@ -8355,26 +8103,27 @@ mod tests { depth: 1, }; + let amount = U256::from(100); let result = evm.before_frame_init(&mut frame).unwrap(); match result { BeforeFrameInitResult::Log(log, _gas) => { assert_eq!( - log.address, SYSTEM_ADDRESS, - "Zero5 + PRAGUE: should emit EIP-7708 Transfer log" + log, + create_eip7708_transfer_log(ADDRESS_A, ADDRESS_B, amount), + "baseline + PRAGUE should emit EIP-7708 Transfer log" ); } - other => panic!( - "Expected Log result with EIP-7708 Transfer, got {:?}", - other - ), + other => { + panic!("Expected Log result with EIP-7708 Transfer under PRAGUE, got {other:?}") + } } } - /// Zero5: CALL with value to Address::ZERO should revert. + /// CALL with value to Address::ZERO should revert. #[test] - fn test_zero5_call_to_zero_address_reverts() { + fn test_baseline_call_to_zero_address_reverts() { let db = CacheDB::new(EmptyDB::default()); - let flags = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); + let flags = ArcHardforkFlags::default(); let mut evm = create_test_evm(db, flags); evm.ctx_mut() @@ -8396,16 +8145,16 @@ mod tests { let result = evm.before_frame_init(&mut frame).unwrap(); assert!( matches!(result, BeforeFrameInitResult::Reverted(_)), - "Zero5 should revert CALL with value to zero address, got {:?}", + "baseline should revert CALL with value to zero address, got {:?}", result, ); } - /// Zero5: CALL from Address::ZERO with value should revert. + /// CALL from Address::ZERO with value should revert. #[test] - fn test_zero5_call_from_zero_address_reverts() { + fn test_baseline_call_from_zero_address_reverts() { let db = CacheDB::new(EmptyDB::default()); - let flags = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); + let flags = ArcHardforkFlags::default(); let mut evm = create_test_evm(db, flags); evm.ctx_mut() @@ -8427,38 +8176,7 @@ mod tests { let result = evm.before_frame_init(&mut frame).unwrap(); assert!( matches!(result, BeforeFrameInitResult::Reverted(_)), - "Zero5 should revert CALL with value from zero address, got {:?}", - result, - ); - } - - /// Pre-Zero5: CALL to Address::ZERO is NOT blocked (backwards compatible). - #[test] - fn test_pre_zero5_call_to_zero_address_allowed() { - let db = CacheDB::new(EmptyDB::default()); - let flags = ArcHardforkFlags::with(&[ArcHardfork::Zero4]); - let mut evm = create_test_evm(db, flags); - - evm.ctx_mut() - .journal_mut() - .load_account(NATIVE_COIN_CONTROL_ADDRESS) - .unwrap(); - - let mut frame = FrameInit { - frame_input: FrameInput::Call(call_input( - CallScheme::Call, - U256::from(100), - ADDRESS_A, - Address::ZERO, - )), - memory: SharedMemory::default(), - depth: 1, - }; - - let result = evm.before_frame_init(&mut frame).unwrap(); - assert!( - matches!(result, BeforeFrameInitResult::Log(_, _)), - "Pre-Zero5 should allow CALL to zero address, got {:?}", + "baseline should revert CALL with value from zero address, got {:?}", result, ); } @@ -8617,7 +8335,7 @@ mod tests { address: MOCK_LOG_ADDRESS, data: LogData::new_unchecked(vec![], Bytes::new()), }); - Ok(PrecompileOutput::new(0, Bytes::new())) + Ok(PrecompileOutput::new(0, Bytes::new(), 0)) }, )) } else { @@ -8647,13 +8365,17 @@ mod tests { scheme: CallScheme::Call, target_address: MOCK_PRECOMPILE, bytecode_address: MOCK_PRECOMPILE, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::from(100)), input: CallInput::Bytes(Bytes::new()), gas_limit: 500_000, is_static: false, caller: ADDRESS_A, return_memory_offset: 0..0, + reservoir: 0, })), memory: SharedMemory::default(), depth: 1, @@ -8697,7 +8419,7 @@ mod tests { #[test] fn test_zero5_reverted_precompile_call_with_value_emits_no_eip7708_log() { use reth_evm::precompiles::DynPrecompile; - use revm::precompile::{PrecompileError, PrecompileId}; + use revm::precompile::{PrecompileId, PrecompileOutput}; const MOCK_PRECOMPILE: Address = address!("ff00000000000000000000000000000000000099"); @@ -8708,7 +8430,10 @@ mod tests { if *address == MOCK_PRECOMPILE { Some(DynPrecompile::new_stateful( PrecompileId::Custom("MOCK_REVERTER".into()), - move |_input| Err(PrecompileError::other("authorization failed")), + // revm 38: `PrecompileError` is `Fatal`-only (propagates as + // EVMError::Custom). To simulate a revert, return Ok with a + // Revert-status output. + move |_input| Ok(PrecompileOutput::revert(0, Bytes::new(), 0)), )) } else { None @@ -8735,13 +8460,17 @@ mod tests { scheme: CallScheme::Call, target_address: MOCK_PRECOMPILE, bytecode_address: MOCK_PRECOMPILE, - known_bytecode: None, + known_bytecode: ( + revm_primitives::B256::ZERO, + revm::bytecode::Bytecode::default(), + ), value: CallValue::Transfer(U256::from(100)), input: CallInput::Bytes(Bytes::new()), gas_limit: 500_000, is_static: false, caller: ADDRESS_A, return_memory_offset: 0..0, + reservoir: 0, })), memory: SharedMemory::default(), depth: 1, @@ -8777,7 +8506,7 @@ mod tests { /// — mirrors `Evm::frame_init` from `revm-handler` (borrow-split variant) /// — see [`revm::handler::EvmTr::frame_init`] /// - /// 3. `arc_network_selfdestruct_impl` in `crates/evm/src/opcode.rs` + /// 3. `arc_network_selfdestruct` in `crates/evm/src/opcode.rs` /// — forked from `revm/crates/interpreter/src/instructions/host.rs` (SELFDESTRUCT) /// — /// @@ -8785,7 +8514,7 @@ mod tests { /// — mirrors revm's `istanbul_sstore_cost` gas calculation #[test] fn revm_version_check() { - const EXPECTED_REVM_VERSION: &str = "34.0.0"; + const EXPECTED_REVM_VERSION: &str = "38.0.0"; let workspace_toml = include_str!("../../../Cargo.toml"); let expected = format!("revm = {{ version = \"{EXPECTED_REVM_VERSION}\""); assert!( diff --git a/crates/evm/src/executor.rs b/crates/evm/src/executor.rs index 87eb2839..81ef5665 100644 --- a/crates/evm/src/executor.rs +++ b/crates/evm/src/executor.rs @@ -20,12 +20,9 @@ use alloy_evm::eth::receipt_builder::ReceiptBuilder; use reth_chainspec::EthChainSpec; use reth_chainspec::Hardforks; use reth_ethereum::{ - evm::{ - primitives::{ - execute::{BlockExecutionError, BlockExecutor}, - Database, OnStateHook, - }, - revm::db::State, + evm::primitives::{ + execute::{BlockExecutionError, BlockExecutor}, + Database, OnStateHook, }, provider::BlockExecutionResult, }; @@ -52,7 +49,6 @@ use arc_execution_config::chainspec::{BaseFeeConfigProvider, BlockGasLimitProvid use arc_execution_config::gas_fee::{ self, arc_calc_next_block_base_fee, decode_base_fee_from_bytes, }; -use arc_execution_config::hardforks::{is_arc_fork_active, ArcHardfork}; use arc_execution_config::native_coin_control::{ compute_is_blocklisted_storage_slot, is_blocklisted_status, }; @@ -62,9 +58,7 @@ use arc_precompiles::system_accounting; use reth_evm::block::StateChangePostBlockSource; use revm::DatabaseCommit; -const ERR_BLOCKLIST_READ_FAILED: &str = "Failed to read beneficiary blocklist status"; const ERR_BLOCK_NUMBER_CONVERSION_FAILED: &str = "Failed to convert block number to u64"; -const ERR_BLOCK_TIMESTAMP_CONVERSION_FAILED: &str = "Failed to convert block timestamp to u64"; /// Result of executing an Arc transaction. #[derive(Debug)] @@ -77,12 +71,20 @@ pub struct ArcTxResult { pub tx_type: T, } -impl TxResult for ArcTxResult { +impl TxResult for ArcTxResult +where + H: Send + 'static, + T: Send + 'static, +{ type HaltReason = H; fn result(&self) -> &ResultAndState { &self.result } + + fn into_result(self) -> ResultAndState { + self.result + } } /// Custom block executor for Arc @@ -106,6 +108,25 @@ pub struct ArcBlockExecutor<'a, Evm, Spec, R: ReceiptBuilder> { gas_used: u64, /// Total blob gas used by transactions in this block. blob_gas_used: u64, + /// Tracks whether a state hook is currently installed. + /// + /// The payload builder calls `set_state_hook(None)` unconditionally before + /// finalization, even when no sparse-trie hook was installed. Only a real + /// `Some(..)` -> `None` transition should flush post-block writes early. + state_hook_active: bool, + /// Tracks whether the post-execution block work (gas accounting writes via + /// `system_accounting::store_gas_values` + state-hook notification) has + /// already run, so it can be invoked early from `set_state_hook(None)` + /// (to keep the sparse-trie state hook live across the write) and + /// idempotently skipped by `finish()` thereafter. + post_block_applied: bool, + /// Stores an error from an early `set_state_hook(None)` post-block write attempt. + /// + /// The payload builder asks the sparse-trie task for the precomputed state root + /// immediately after clearing the hook. If the early post-block write failed, `finish()` + /// must surface that error instead of retrying after the hook has been detached and the + /// sparse-trie root may already have been computed from the pre-error state. + post_block_error: Option, } impl<'a, Evm, Spec, R> ArcBlockExecutor<'a, Evm, Spec, R> @@ -125,6 +146,9 @@ where blob_gas_used: 0, system_caller: SystemCaller::new(spec.clone()), receipt_builder, + state_hook_active: false, + post_block_applied: false, + post_block_error: None, } } @@ -140,24 +164,6 @@ where BlockExecutionError::msg(ERR_BLOCK_NUMBER_CONVERSION_FAILED) }) } - - /// Current block timestamp as `u64`. - /// - /// Needed alongside [`Self::block_number_u64`] because Arc hardforks may activate - /// either by block or by timestamp (e.g. testnet Zero5/Zero6, all networks' Zero7+); - /// runtime hardfork gating must consult both dimensions via - /// [`arc_execution_config::hardforks::is_arc_fork_active`]. - fn block_timestamp_u64(&self) -> Result { - let block_timestamp = self.evm.block().timestamp(); - block_timestamp.try_into().map_err(|err| { - tracing::error!( - error = %err, - block_timestamp = %block_timestamp, - "Failed to convert block timestamp to u64" - ); - BlockExecutionError::msg(ERR_BLOCK_TIMESTAMP_CONVERSION_FAILED) - }) - } } fn validate_beneficiary_not_blocklisted( @@ -172,14 +178,14 @@ fn validate_beneficiary_not_blocklisted( ) .map(is_blocklisted_status) .map_err(|error| { - let reason = format!("NativeCoinControl blocklist storage read failed: {error}"); tracing::error!( - error = %reason, - header_beneficiary = %header_beneficiary, - block_number = block_number, - "Blocklist status read failed for block beneficiary" + %error, + %header_beneficiary, + block_number, + "NativeCoinControl blocklist storage read failed for block beneficiary" ); - BlockValidationError::msg(ERR_BLOCKLIST_READ_FAILED) + // A storage-read should be classify as internal error + BlockExecutionError::other(error) })?; if is_blocklisted { @@ -194,17 +200,68 @@ fn validate_beneficiary_not_blocklisted( Ok(()) } -impl<'db, DB, E, Spec, R> ArcBlockExecutor<'_, E, Spec, R> +impl ArcBlockExecutor<'_, E, Spec, R> where - DB: Database + 'db, E: Evm< - DB = &'db mut State, + DB: alloy_evm::block::state::StateDB, Tx: FromRecoveredTx + FromTxWithEncoded, >, Spec: EthExecutorSpec + Hardforks + EthChainSpec + BlockGasLimitProvider + BaseFeeConfigProvider, R: ReceiptBuilder>, { + /// Runs the Arc-specific post-block work: computes the next-block gas values + /// (raw + smoothed + next base fee), writes them to the `SystemAccounting` + /// precompile via `store_gas_values`, and notifies the state hook of the + /// resulting state diff. + /// + /// Idempotent — subsequent calls are no-ops. Called early from + /// `set_state_hook(None)` (so the writes flow through the still-active + /// sparse-trie state hook) and again from `finish()` for execution paths + /// that do not clear the hook first. + fn apply_post_block_writes(&mut self) -> Result<(), BlockExecutionError> { + if self.post_block_applied { + return Ok(()); + } + + let block_number = self.block_number_u64()?; + + let fee_params = protocol_config::retrieve_fee_params(&mut self.evm) + .inspect_err(|e| { + tracing::error!( + error = %e, + block_number, + "Failed to retrieve fee params from ProtocolConfig" + ); + }) + .ok(); + let gas_values = self.compute_gas_values(block_number, fee_params)?; + + // ADR-004: enforce extra_data matches what is computed, but only when executing an + // existing payload (extra_data already set by consensus). During block building the + // executor writes extra_data itself, so it is empty at this point — skip validation. + if !self.ctx.extra_data.is_empty() { + self.validate_extra_data_base_fee(block_number, gas_values.nextBaseFee)?; + } + + let state = system_accounting::store_gas_values(block_number, gas_values, &mut self.evm) + .map_err(|e| { + tracing::error!(error = %e, "Failed to store gas values to SystemAccounting"); + BlockExecutionError::Internal(InternalBlockExecutionError::Other(Box::new(e))) + })?; + + // BalanceIncrements is semantically imprecise (this is a storage write, not a balance + // change), but it's the least-wrong variant available in the upstream enum, and functionally + // equivalent to others. + self.system_caller.on_state( + StateChangeSource::PostBlock(StateChangePostBlockSource::BalanceIncrements), + &state, + ); + + self.post_block_applied = true; + Ok(()) + } + /// Validates that block `extra_data` encodes the same next base fee that this executor /// computed for the current block. fn validate_extra_data_base_fee( @@ -224,54 +281,7 @@ where Ok(()) } - /// Computes `GasValues` using the pre-Zero5 path - fn compute_gas_values_legacy( - &mut self, - block_number: u64, - fee_params: Option, - ) -> Result { - let Some(fee_params) = fee_params else { - return Ok(system_accounting::GasValues { - gasUsed: self.gas_used, - gasUsedSmoothed: self.gas_used, - nextBaseFee: 0, - }); - }; - - let parent_block_number = block_number.saturating_sub(1); - let parent_gas_values = - system_accounting::retrieve_gas_values(parent_block_number, &mut self.evm).map_err( - |e| BlockExecutionError::Internal(InternalBlockExecutionError::Other(Box::new(e))), - )?; - - let calculated_smoothed_gas_used = gas_fee::determine_ema_parent_gas_used( - parent_gas_values.gasUsedSmoothed, - self.gas_used, - fee_params.alpha, - ); - - let mut next_base_fee: u64 = 0; - if let Some(smoothed_gas_used) = calculated_smoothed_gas_used { - let raw = arc_calc_next_block_base_fee( - smoothed_gas_used, - self.evm.block().gas_limit(), - self.evm.block().basefee(), - fee_params.kRate, - fee_params.inverseElasticityMultiplier, - ); - next_base_fee = protocol_config::determine_bounded_base_fee(&fee_params, raw); - } - - let smoothed_gas_used = calculated_smoothed_gas_used.unwrap_or(self.gas_used); - - Ok(system_accounting::GasValues { - gasUsed: self.gas_used, - gasUsedSmoothed: smoothed_gas_used, - nextBaseFee: next_base_fee, - }) - } - - /// Computes `GasValues` using the ADR-0004 spec (Zero5+). + /// Computes `GasValues` using the ADR-0004 spec. /// /// Validates the on-chain `FeeParams` against the chainspec `BaseFeeConfig` bounds, /// substituting per-field defaults for any out-of-range value. If ProtocolConfig is @@ -286,7 +296,7 @@ where if fee_params.is_none() { tracing::warn!( block_number, - "ProtocolConfig unavailable post-Zero5; computing next_base_fee with chainspec defaults" + "ProtocolConfig unavailable; computing next_base_fee with chainspec defaults" ); } @@ -339,16 +349,16 @@ where } } -impl<'db, DB, E, Spec, R> BlockExecutor for ArcBlockExecutor<'_, E, Spec, R> +impl BlockExecutor for ArcBlockExecutor<'_, E, Spec, R> where - DB: Database + 'db, E: Evm< - DB = &'db mut State, + DB: alloy_evm::block::state::StateDB, Tx: FromRecoveredTx + FromTxWithEncoded, >, Spec: EthExecutorSpec + Hardforks + EthChainSpec + BlockGasLimitProvider + BaseFeeConfigProvider, R: ReceiptBuilder>, + ::TxType: Send + 'static, { type Transaction = R::Transaction; type Receipt = R::Receipt; @@ -356,47 +366,38 @@ where type Result = ArcTxResult::TxType>; fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> { - // Spurious Dragon hardfork is enabled - self.evm.db_mut().set_state_clear_flag(true); + // EIP-161 state clearing is handled by revm's Journal under reth 2.2; Spurious + // Dragon is always active on Arc, so no explicit set_state_clear_flag call. - // Zero5+ pre-execution checks: beneficiary blocklist, gas limit validation + // Arc pre-execution checks: beneficiary blocklist, gas limit validation. let block_number = self.block_number_u64()?; - let block_timestamp = self.block_timestamp_u64()?; - - if is_arc_fork_active( - &self.chain_spec, - ArcHardfork::Zero5, - block_number, - block_timestamp, - ) { - // EIP-2935: persist parent block hash in history storage contract. - // Internally gates on Prague activation and is a no-op at block 0 (genesis). - self.system_caller - .apply_blockhashes_contract_call(self.ctx.parent_hash, &mut self.evm)?; - - let beneficiary = self.evm.block().beneficiary(); - validate_beneficiary_not_blocklisted(self.evm.db_mut(), beneficiary, block_number)?; - - // ADR-0003: Stateful gas limit validation against ProtocolConfig (Zero5+) - let block_gas_limit = self.evm.block().gas_limit(); - let fee_params = protocol_config::retrieve_fee_params(&mut self.evm).inspect_err(|err| { - tracing::warn!(error = ?err, block_number, "Failed to get fee params from ProtocolConfig for gas limit validation"); - }).ok(); - - let gas_limit_config = self.chain_spec.block_gas_limit_config(block_number); - let expected = - protocol_config::expected_gas_limit(fee_params.as_ref(), &gas_limit_config); - - if block_gas_limit != expected { - return Err(BlockExecutionError::Validation( - BlockValidationError::Other( - format!( - "block gas limit {block_gas_limit} does not match expected {expected}" - ) + + // EIP-2935: persist parent block hash in history storage contract. + // Internally gates on Prague activation and is a no-op at block 0 (genesis). + self.system_caller + .apply_blockhashes_contract_call(self.ctx.parent_hash, &mut self.evm)?; + + let beneficiary = self.evm.block().beneficiary(); + validate_beneficiary_not_blocklisted(self.evm.db_mut(), beneficiary, block_number)?; + + // ADR-0003: Stateful gas limit validation against ProtocolConfig. + let block_gas_limit = self.evm.block().gas_limit(); + let fee_params = protocol_config::retrieve_fee_params(&mut self.evm) + .inspect_err(|err| { + tracing::warn!(error = ?err, block_number, "Failed to get fee params from ProtocolConfig for gas limit validation"); + }) + .ok(); + + let gas_limit_config = self.chain_spec.block_gas_limit_config(block_number); + let expected = protocol_config::expected_gas_limit(fee_params.as_ref(), &gas_limit_config); + + if block_gas_limit != expected { + return Err(BlockExecutionError::Validation( + BlockValidationError::Other( + format!("block gas limit {block_gas_limit} does not match expected {expected}") .into(), - ), - )); - } + ), + )); } Ok(()) @@ -440,7 +441,7 @@ where }) } - fn commit_transaction(&mut self, output: Self::Result) -> Result { + fn commit_transaction(&mut self, output: Self::Result) -> alloy_evm::block::GasOutput { let ArcTxResult { result: ResultAndState { result, state }, blob_gas_used, @@ -450,9 +451,8 @@ where self.system_caller .on_state(StateChangeSource::Transaction(self.receipts.len()), &state); - let gas_used = result.gas_used(); + let gas_used = result.tx_gas_used(); - // append gas used self.gas_used = self .gas_used .checked_add(gas_used) @@ -461,7 +461,6 @@ where // Cancun is always active for arc self.blob_gas_used = self.blob_gas_used.saturating_add(blob_gas_used); - // Push transaction changeset and calculate header bloom filter for receipt. self.receipts .push(self.receipt_builder.build_receipt(ReceiptBuilderCtx { tx_type, @@ -471,10 +470,9 @@ where cumulative_gas_used: self.gas_used, })); - // Commit the state changes. self.evm.db_mut().commit(state); - Ok(gas_used) + alloy_evm::block::GasOutput::new(gas_used) } fn finish( @@ -483,53 +481,15 @@ where // EIP-6110 not activated let requests = Requests::default(); - let block_number = self.block_number_u64()?; - let block_timestamp = self.block_timestamp_u64()?; - - // At the end of the block, call a system contract (precompile) to persist gas accounting - // state: raw gas used, smoothed gas used, and the next block's base fee. - let fee_params = protocol_config::retrieve_fee_params(&mut self.evm) - .inspect_err(|e| { - tracing::error!( - error = %e, - block_number, - "Failed to retrieve fee params from ProtocolConfig" - ); - }) - .ok(); - let is_zero5 = is_arc_fork_active( - &self.chain_spec, - ArcHardfork::Zero5, - block_number, - block_timestamp, - ); - let gas_values = if is_zero5 { - // ADR-0004 implementation: compute gas values within local bounds - self.compute_gas_values(block_number, fee_params)? - } else { - self.compute_gas_values_legacy(block_number, fee_params)? - }; - - // ADR-004: enforce extra_data matches what is computed, but only when executing an - // existing payload (extra_data already set by consensus). During block building the - // executor writes extra_data itself, so it is empty at this point — skip validation. - if is_zero5 && !self.ctx.extra_data.is_empty() { - self.validate_extra_data_base_fee(block_number, gas_values.nextBaseFee)?; + if let Some(err) = self.post_block_error.take() { + return Err(err); } - let state = system_accounting::store_gas_values(block_number, gas_values, &mut self.evm) - .map_err(|e| { - tracing::error!(error = %e, "Failed to store gas values to SystemAccounting"); - BlockExecutionError::Internal(InternalBlockExecutionError::Other(Box::new(e))) - })?; - - // BalanceIncrements is semantically imprecise (this is a storage write, not a balance - // change), but it's the least-wrong variant available in the upstream enum, and functionally - // equivalent to others. - self.system_caller.on_state( - StateChangeSource::PostBlock(StateChangePostBlockSource::BalanceIncrements), - &state, - ); + // Runs post-block gas-accounting writes idempotently — no-op if they + // were already applied by `set_state_hook(None)` (the payload-builder + // path that needs the writes streamed through the still-live state + // hook for the sparse-trie pipeline). + self.apply_post_block_writes()?; Ok(( self.evm, @@ -547,6 +507,34 @@ where } fn set_state_hook(&mut self, hook: Option>) { + // When the payload builder transitions from an installed hook to "no + // more state updates" by clearing the hook (the canonical + // end-of-stream signal for the shared sparse-trie pipeline), flush the + // post-block writes through the still-active hook FIRST. This keeps + // the sparse-trie's view of state in sync with what `bundle_state` + // will hold once `finish()` runs — without this, the SystemAccounting + // `store_gas_values` write happens inside `finish()` after the hook + // has been dropped, the sparse-trie misses it, and its computed + // state_root diverges from canonical re-execution by exactly that + // delta. + // + // A bare `set_state_hook(None)` is also used by the payload builder + // when there was no hook to clear. That path must keep the normal + // `finish()` ordering, so only flush on an actual Some -> None + // transition. + // + // Errors are intentionally deferred here: `set_state_hook` is not a + // fallible operation in the `BlockExecutor` trait, so we cannot + // propagate. `finish()` surfaces the stored error instead of retrying + // after the hook is detached, because the sparse-trie root may already + // have been requested from the pre-error state by then. + let clearing_active_hook = hook.is_none() && self.state_hook_active; + if let Some(Err(err)) = (clearing_active_hook && !self.post_block_applied) + .then(|| self.apply_post_block_writes()) + { + self.post_block_error = Some(err); + } + self.state_hook_active = hook.is_some(); self.system_caller.with_state_hook(hook); } @@ -565,11 +553,14 @@ mod tests { use super::*; + use reth_ethereum::evm::revm::db::State; + use alloy_genesis::Genesis; use alloy_primitives::address; use alloy_primitives::map::HashMap; use alloy_primitives::B256 as AlloyB256; use alloy_primitives::KECCAK256_EMPTY; + use arc_execution_config::hardforks::ArcHardfork; use reth_chainspec::{EthChainSpec, ForkCondition}; use reth_evm::ConfigureEvm; use reth_evm::EvmEnv; @@ -577,7 +568,7 @@ mod tests { use revm::{ context::{BlockEnv, CfgEnv}, database::InMemoryDB, - state::{AccountInfo, Bytecode}, + state::{AccountInfo, Bytecode, EvmState}, }; use revm_primitives::ruint::aliases::U256; use revm_primitives::{hardfork::SpecId, keccak256}; @@ -620,10 +611,11 @@ mod tests { } } - // Input values used only for pre-Zero5 patch_fee_params calls — not used as expected outputs. - const GENESIS_K_RATE: u64 = 200; - const GENESIS_INVERSE_ELASTICITY_MULTIPLIER: u64 = 5000; const GAS_USED: u64 = 100_000; + // Mirrors `minBaseFee` / `maxBaseFee` in `assets/localdev/genesis.config.ts`. + // Used to clamp expected `nextBaseFee` in tests where the ProtocolConfig storage is intact. + const GENESIS_MIN_BASE_FEE: u64 = 20_000_000_000; + const GENESIS_MAX_BASE_FEE: u64 = 20_000_000_000_000; fn get_mock_block_env() -> BlockEnv { BlockEnv { @@ -642,6 +634,7 @@ mod tests { withdrawals: None, extra_data: Default::default(), tx_count_hint: None, + slot_number: None, } } @@ -696,6 +689,7 @@ mod tests { withdrawals: None, extra_data: Default::default(), tx_count_hint: None, + slot_number: None, }; let mut executor = ArcBlockExecutor::new( @@ -738,7 +732,8 @@ mod tests { block_env.basefee, defaults.k_rate, defaults.inverse_elasticity_multiplier, - ); + ) + .clamp(GENESIS_MIN_BASE_FEE, GENESIS_MAX_BASE_FEE); assert_eq!(stored.nextBaseFee, expected_next_base_fee); } @@ -831,27 +826,6 @@ mod tests { .expect("insert storage"); } - #[test] - fn test_zero4_invalid_alpha_stores_zero_next_base_fee() { - let block_env = get_mock_block_env(); - let chain_spec = localdev_with_hardforks(&[(ArcHardfork::Zero3, ForkCondition::Block(0))]); - - let mut db = InMemoryDB::default(); - insert_alloc_into_db(&mut db, chain_spec.genesis()); - patch_fee_params( - &mut db, - 255, - GENESIS_K_RATE, - GENESIS_INVERSE_ELASTICITY_MULTIPLIER, - ); - - let stored = run_executor_finish_and_query_gas_values(chain_spec, &block_env, &mut db); - - assert_eq!(stored.gasUsed, GAS_USED); - assert_eq!(stored.gasUsedSmoothed, GAS_USED); - assert_eq!(stored.nextBaseFee, 0); - } - #[test] fn test_zero5_executor_out_of_range_alpha_uses_default() { // alpha=255 exceeds alpha.max for localdev; zero5 will substitute alpha.default @@ -880,7 +854,8 @@ mod tests { block_env.basefee, defaults.k_rate, defaults.inverse_elasticity_multiplier, - ); + ) + .clamp(GENESIS_MIN_BASE_FEE, GENESIS_MAX_BASE_FEE); assert_eq!(stored.nextBaseFee, expected_next_base_fee); } @@ -910,7 +885,8 @@ mod tests { block_env.basefee, defaults.k_rate, defaults.inverse_elasticity_multiplier, - ); + ) + .clamp(GENESIS_MIN_BASE_FEE, GENESIS_MAX_BASE_FEE); assert_eq!(stored.nextBaseFee, expected_next_base_fee); } @@ -935,7 +911,8 @@ mod tests { block_env.basefee, defaults.k_rate, defaults.inverse_elasticity_multiplier, - ); + ) + .clamp(GENESIS_MIN_BASE_FEE, GENESIS_MAX_BASE_FEE); assert_eq!(stored.nextBaseFee, expected_next_base_fee); } @@ -964,7 +941,8 @@ mod tests { block_env.basefee, CUSTOM_K_RATE, CUSTOM_ELASTICITY, - ); + ) + .clamp(GENESIS_MIN_BASE_FEE, GENESIS_MAX_BASE_FEE); assert_eq!(stored.nextBaseFee, expected_next_base_fee); } @@ -1040,21 +1018,20 @@ mod tests { } #[test] - fn test_beneficiary_validation_skipped_before_zero5() { - // Test that beneficiary validation is skipped for blocks before Zero5 hardfork + fn test_beneficiary_validation_enforced_before_zero5_activation() { + // Even when chain metadata does not activate Zero5, Arc execution uses the Zero6 baseline. let chain_spec = localdev_with_hardforks(&[(ArcHardfork::Zero4, ForkCondition::Block(0))]); let mut db = InMemoryDB::default(); insert_alloc_into_db(&mut db, chain_spec.genesis()); + let blocklisted_beneficiary = address!("0000000000000000000000000000000000000bad"); + mark_address_as_blocklisted(&mut db, blocklisted_beneficiary); let evm_config = create_evm_config(chain_spec.clone()); - // Use a wrong beneficiary - should still pass because we're before Zero5 - let wrong_beneficiary = address!("0000000000000000000000000000000000000bad"); - let mut block_env = get_mock_block_env(); - block_env.number = U256::from(0); // Before Zero5 - block_env.beneficiary = wrong_beneficiary; + block_env.number = U256::from(0); + block_env.beneficiary = blocklisted_beneficiary; let cfg_env = CfgEnv::new() .with_chain_id(chain_spec.chain_id()) @@ -1073,11 +1050,10 @@ mod tests { evm_config.inner.executor_factory.receipt_builder(), ); - // This should succeed because validation is skipped before Zero5 let result = executor.apply_pre_execution_changes(); assert!( - result.is_ok(), - "Beneficiary validation should be skipped before Zero5 hardfork" + matches!(result, Err(BlockExecutionError::Validation(_))), + "beneficiary validation should be enforced regardless of Zero5 activation: {result:?}" ); } @@ -1190,7 +1166,7 @@ mod tests { } #[test] - fn test_beneficiary_validation_fails_when_blocklist_read_fails() { + fn test_beneficiary_blocklist_read_failure_maps_to_internal_not_validation() { let chain_spec = LOCAL_DEV.clone(); let mut base_db = InMemoryDB::default(); @@ -1221,19 +1197,217 @@ mod tests { let result = executor.apply_pre_execution_changes(); match result { - Err(BlockExecutionError::Validation(validation_err)) => { - let err_msg = validation_err.to_string(); + // The read fault must surface as internal (retryable), specifically Other, not a + // block-invalid verdict; the underlying DB error is preserved as the error's source. + Err(BlockExecutionError::Internal( + internal_err @ InternalBlockExecutionError::Other(_), + )) => { assert!( - err_msg.contains(ERR_BLOCKLIST_READ_FAILED), - "Expected validation error containing '{}', got: {}", - ERR_BLOCKLIST_READ_FAILED, - err_msg + internal_err + .to_string() + .contains("forced blocklist storage read failure"), + "Expected the underlying DB error to be preserved, got: {internal_err}" ); } - other => panic!( - "Expected BlockExecutionError::Validation containing '{}', got: {:?}", - ERR_BLOCKLIST_READ_FAILED, other - ), + other => panic!("Expected BlockExecutionError::Internal(Other), got: {other:?}"), } } + + /// Regression guard for sparse-trie post-block error handling. + /// + /// Reproduces the payload-builder sequence where `set_state_hook(None)` + /// attempts to flush post-block writes before clearing the hook, but that + /// early flush fails. It asserts that: + /// + /// 1. The error is preserved for `finish()` and the idempotency flag stays + /// unset, so the executor does not pretend the post-block write reached + /// either `bundle_state` or the sparse-trie hook. + /// 2. The subsequent `finish()` returns the stored early error instead of + /// retrying after the hook has been detached, when the sparse-trie root + /// may already have been computed from the pre-error state. + #[test] + fn test_set_state_hook_none_preserves_post_block_error_without_retrying_after_hook_clear() { + let block_env = get_mock_block_env(); + let chain_spec = LOCAL_DEV.clone(); + + let mut db = InMemoryDB::default(); + insert_alloc_into_db(&mut db, chain_spec.genesis()); + + let cfg_env = CfgEnv::new() + .with_chain_id(chain_spec.chain_id()) + .with_spec_and_mainnet_gas_params(SpecId::PRAGUE); + let evm_env = EvmEnv { + cfg_env, + block_env: block_env.clone(), + }; + + let evm_config = create_evm_config(chain_spec.clone()); + + let mut state = State::builder().with_database(&mut db).build(); + let evm = evm_config.evm_with_env(&mut state, evm_env); + + let mut ctx = get_mock_execution_ctx(); + ctx.extra_data = arc_execution_config::gas_fee::encode_base_fee_to_bytes(1); + + let mut executor = ArcBlockExecutor::new( + evm, + ctx, + chain_spec.clone(), + evm_config.inner.executor_factory.receipt_builder(), + ); + executor.gas_used = GAS_USED; + + let hook = |_: StateChangeSource, _: &EvmState| {}; + + executor.set_state_hook(Some(Box::new(hook))); + executor.set_state_hook(None); + assert!( + executor.post_block_error.is_some(), + "set_state_hook(None) must preserve post-block errors for finish()" + ); + assert!( + !executor.post_block_applied, + "failed post-block writes must not mark the idempotency flag" + ); + + // Make a retry succeed if finish() attempted one. The expected error below + // proves finish() returned the stored early error instead. + executor.ctx.extra_data = Default::default(); + + let err = executor + .finish() + .expect_err("finish must surface the stored post-block error without retrying"); + let err_msg = err.to_string(); + assert!( + err_msg.contains("extra_data base fee mismatch"), + "unexpected error: {err_msg}" + ); + } + + /// Regression guard for the non-sparse-trie payload-builder path. + /// + /// The payload builder calls `set_state_hook(None)` unconditionally before + /// finalization, even when it never installed a sparse-trie state hook. A + /// bare clear must remain a no-op for post-block writes, otherwise the + /// normal `finish()` ordering changes and block sealing can diverge from + /// `newPayload` re-execution. + #[test] + fn test_set_state_hook_none_without_active_hook_defers_post_block_writes_to_finish() { + let block_env = get_mock_block_env(); + let chain_spec = LOCAL_DEV.clone(); + + let mut db = InMemoryDB::default(); + insert_alloc_into_db(&mut db, chain_spec.genesis()); + + let cfg_env = CfgEnv::new() + .with_chain_id(chain_spec.chain_id()) + .with_spec_and_mainnet_gas_params(SpecId::PRAGUE); + let evm_env = EvmEnv { + cfg_env, + block_env: block_env.clone(), + }; + + let evm_config = create_evm_config(chain_spec.clone()); + + let mut state = State::builder().with_database(&mut db).build(); + let evm = evm_config.evm_with_env(&mut state, evm_env); + + let mut executor = ArcBlockExecutor::new( + evm, + get_mock_execution_ctx(), + chain_spec.clone(), + evm_config.inner.executor_factory.receipt_builder(), + ); + executor.gas_used = GAS_USED; + + executor.set_state_hook(None); + assert!( + !executor.post_block_applied, + "bare set_state_hook(None) must not flush post-block writes early" + ); + + let _ = executor.finish().expect("finish"); + } + + /// Regression guard for the share-sparse-trie state-root divergence. + /// + /// Reproduces the payload-builder sequence — `set_state_hook(Some(...))` → + /// `set_state_hook(None)` → `finish()` — and asserts that: + /// + /// 1. The state hook installed before the transition observes the post-block + /// `SystemAccounting.store_gas_values` write exactly once, fired through + /// the still-active hook by `set_state_hook(None)`. This is the property + /// the sparse-trie pipeline relies on for an accurate computed state + /// root. + /// 2. The subsequent `finish()` is idempotent: no second hook fire, no + /// second `store_gas_values` overwrite (which would invalidate the + /// sparse-trie's view of the final state). + #[test] + fn test_set_state_hook_none_fires_post_block_writes_through_active_hook_and_finish_is_idempotent( + ) { + use alloc::sync::Arc; + use core::sync::atomic::{AtomicUsize, Ordering}; + use revm::state::EvmState; + use revm_primitives::hardfork::SpecId; + + let block_env = get_mock_block_env(); + let chain_spec = LOCAL_DEV.clone(); + + let mut db = InMemoryDB::default(); + insert_alloc_into_db(&mut db, chain_spec.genesis()); + + let cfg_env = CfgEnv::new() + .with_chain_id(chain_spec.chain_id()) + .with_spec_and_mainnet_gas_params(SpecId::PRAGUE); + let evm_env = EvmEnv { + cfg_env, + block_env: block_env.clone(), + }; + + let evm_config = create_evm_config(chain_spec.clone()); + + let mut state = State::builder().with_database(&mut db).build(); + let evm = evm_config.evm_with_env(&mut state, evm_env); + + let mut executor = ArcBlockExecutor::new( + evm, + get_mock_execution_ctx(), + chain_spec.clone(), + evm_config.inner.executor_factory.receipt_builder(), + ); + executor.gas_used = GAS_USED; + + // Counts only post-block writes, mirroring what the sparse-trie hook + // would see (other hook variants — pre-block, per-tx — are not part of + // this regression). + let post_block_hits = Arc::new(AtomicUsize::new(0)); + let hook_counter = post_block_hits.clone(); + let hook = move |source: StateChangeSource, _state: &EvmState| { + if matches!( + source, + StateChangeSource::PostBlock(StateChangePostBlockSource::BalanceIncrements) + ) { + hook_counter.fetch_add(1, Ordering::SeqCst); + } + }; + + executor.set_state_hook(Some(Box::new(hook))); + + // Property 1: clearing the hook flushes the post-block write through + // the hook that's about to be detached. + executor.set_state_hook(None); + assert_eq!( + post_block_hits.load(Ordering::SeqCst), + 1, + "set_state_hook(None) must fire the post-block write through the still-active hook" + ); + + // Property 2: finish() is idempotent — no double-write, no double-fire. + let _ = executor.finish().expect("finish"); + assert_eq!( + post_block_hits.load(Ordering::SeqCst), + 1, + "finish() must not re-fire post-block writes after set_state_hook(None) already did" + ); + } } diff --git a/crates/evm/src/frame_result.rs b/crates/evm/src/frame_result.rs index 58565c0c..16acd69d 100644 --- a/crates/evm/src/frame_result.rs +++ b/crates/evm/src/frame_result.rs @@ -48,7 +48,7 @@ pub fn create_oog_frame_result(frame_init: &FrameInit) -> FrameResult { FrameInput::Call(call_input) => { // All gas is consumed on OOG let mut gas_counter = Gas::new(call_input.gas_limit); - let _ = gas_counter.record_cost(call_input.gas_limit); + let _ = gas_counter.record_regular_cost(call_input.gas_limit); let interpreter_result = InterpreterResult::new( InstructionResult::OutOfGas, @@ -65,7 +65,7 @@ pub fn create_oog_frame_result(frame_init: &FrameInit) -> FrameResult { FrameInput::Create(create_input) => { // All gas is consumed on OOG let mut gas_counter = Gas::new(create_input.gas_limit()); - let _ = gas_counter.record_cost(create_input.gas_limit()); + let _ = gas_counter.record_regular_cost(create_input.gas_limit()); let interpreter_result = InterpreterResult::new( InstructionResult::OutOfGas, @@ -137,6 +137,7 @@ mod tests { U256::ZERO, Bytes::from(vec![0x60, 0x00, 0x60, 0x00, 0xF3]), // minimal bytecode 100_000, + 0, ); let frame_init = FrameInit { diff --git a/crates/evm/src/handler.rs b/crates/evm/src/handler.rs index 1bb58694..1ecc42f7 100644 --- a/crates/evm/src/handler.rs +++ b/crates/evm/src/handler.rs @@ -15,11 +15,11 @@ // limitations under the License. use alloy_primitives::{Address, U256}; -use arc_execution_config::hardforks::ArcHardforkFlags; +use arc_execution_config::hardforks::{ArcHardfork, ArcHardforkFlags}; use arc_execution_config::native_coin_control::{ compute_is_blocklisted_storage_slot, is_blocklisted_status, }; -use arc_precompiles::helpers::ERR_BLOCKED_ADDRESS; +use arc_precompiles::helpers::{ERR_BLOCKED_ADDRESS, ERR_SELFDESTRUCTED_BALANCE_INCREASED}; use arc_precompiles::NATIVE_COIN_CONTROL_ADDRESS; use revm::inspector::{Inspector, InspectorEvmTr, InspectorHandler}; use revm::{ @@ -37,8 +37,6 @@ use revm_primitives::TxKind; pub struct ArcEvmHandler { mainnet: MainnetHandler>, /// Feature flags for Arc hardforks active at the current block. - /// Retained for future hardfork-gated handler behavior (e.g. new precompiles). - #[allow(dead_code)] hardfork_flags: ArcHardforkFlags, } @@ -64,7 +62,11 @@ where type HaltReason = HaltReason; #[inline] - fn pre_execution(&self, evm: &mut Self::Evm) -> Result { + fn pre_execution( + &self, + evm: &mut Self::Evm, + init_and_floor_gas: &mut revm_context_interface::cfg::gas::InitialAndFloorGas, + ) -> Result { let ctx = evm.ctx(); let tx = ctx.tx(); let caller = tx.caller(); @@ -76,7 +78,7 @@ where .load_account(NATIVE_COIN_CONTROL_ADDRESS)?; self.check_blocklist(evm, caller, &tx_kind, tx_value)?; - self.mainnet.pre_execution(evm) + self.mainnet.pre_execution(evm, init_and_floor_gas) } #[inline] @@ -97,6 +99,17 @@ where #[allow(clippy::arithmetic_side_effects)] let total_fee_amount = U256::from(effective_gas_price) * U256::from(gas_used); + // Crediting a self-destructed beneficiary silently burns the fee at commit: reject the + // condition instead, matching the native-coin precompile credit guard. + if self.hardfork_flags.is_active(ArcHardfork::Zero8) { + let account = evm.ctx_mut().journal_mut().load_account(beneficiary)?; + if account.is_selfdestructed() { + return Err( + InvalidTransaction::Str(ERR_SELFDESTRUCTED_BALANCE_INCREASED.into()).into(), + ); + } + } + // Transfer the total fee to the beneficiary (both base fee and priority fee) evm.ctx_mut() .journal_mut() @@ -180,6 +193,7 @@ mod tests { interpreter::{CallOutcome, Gas, InstructionResult, InterpreterResult}, MainBuilder, MainContext, }; + use rstest::rstest; use std::convert::Infallible; #[test] @@ -514,6 +528,98 @@ mod tests { ); } + /// A beneficiary self-destructed in the same transaction must not be silently credited (and + /// burned at commit). Under Zero8 the credit is rejected; before Zero8 the legacy behaviour + /// (unconditional credit) is preserved. + #[rstest] + #[case( + "zero8", + ArcHardforkFlags::with(&[ArcHardfork::Zero6, ArcHardfork::Zero7, ArcHardfork::Zero8]), + true + )] + #[case( + "pre-zero8", + ArcHardforkFlags::with(&[ArcHardfork::Zero6, ArcHardfork::Zero7]), + false + )] + fn test_reward_beneficiary_rejects_selfdestructed_under_zero8( + #[case] case_name: &str, + #[case] hardfork_flags: ArcHardforkFlags, + #[case] expect_error: bool, + ) { + let beneficiary = address!("1200000000000000000000000000000000000012"); + let caller = address!("3400000000000000000000000000000000000034"); + let gas_price = 10u128; + let gas_used = 21000u64; + let db: CacheDB> = CacheDB::new(EmptyDB::default()); + let mut evm = Context::mainnet().with_db(db).build_mainnet(); + evm.block.beneficiary = beneficiary; + evm.block.basefee = 7; + evm.tx.caller = caller; + evm.tx.gas_price = gas_price; + + let mut exec_result = FrameResult::Call(CallOutcome::new( + InterpreterResult::new( + InstructionResult::Return, + alloy_primitives::Bytes::new(), + Gas::new_spent(gas_used), + ), + 0..0, + )); + + evm.journaled_state.load_account(beneficiary).unwrap(); + evm.journaled_state + .state + .get_mut(&beneficiary) + .unwrap() + .mark_selfdestruct(); + let initial_balance = evm + .journaled_state + .load_account(beneficiary) + .unwrap() + .info + .balance; + + let handler: ArcEvmHandler<_, EVMError> = ArcEvmHandler::new(hardfork_flags); + let result = handler.reward_beneficiary(&mut evm, &mut exec_result); + let final_balance = evm + .journaled_state + .load_account(beneficiary) + .unwrap() + .info + .balance; + + if expect_error { + match result { + Err(EVMError::Transaction(InvalidTransaction::Str(msg))) => { + assert_eq!( + msg, ERR_SELFDESTRUCTED_BALANCE_INCREASED, + "'{case_name}' should return the selfdestructed balance error" + ); + } + other => { + panic!( + "'{case_name}' should reject with EVMError::Transaction(InvalidTransaction::Str), got: {:?}", + other + ); + } + } + assert_eq!( + final_balance, initial_balance, + "'{case_name}' beneficiary balance must be unchanged" + ); + } else { + if let Err(err) = result { + panic!("'{case_name}' should credit the beneficiary, got: {err:?}"); + } + assert_eq!( + final_balance, + initial_balance + U256::from(gas_price * gas_used as u128), + "'{case_name}' beneficiary should be credited the full fee" + ); + } + } + #[derive(Debug)] struct BlocklistTestCase { name: &'static str, @@ -639,7 +745,9 @@ mod tests { let handler: ArcEvmHandler<_, EVMError> = ArcEvmHandler::new(ArcHardforkFlags::default()); - let result = handler.pre_execution(&mut evm); + let mut init_and_floor_gas = + revm_context_interface::cfg::gas::InitialAndFloorGas::default(); + let result = handler.pre_execution(&mut evm, &mut init_and_floor_gas); // Check caller balance after pre_execution to verify correct gas deduction behavior let final_caller_balance = evm @@ -731,7 +839,7 @@ mod tests { assert!(result.is_ok(), "validate_initial_tx_gas should succeed"); assert_eq!( - result.unwrap().initial_gas, + result.unwrap().initial_total_gas, 21000, "Native value transfer should cost exactly 21,000 gas (no blocklist surcharge)" ); diff --git a/crates/evm/src/log.rs b/crates/evm/src/log.rs index 21a725bf..4ac0c99e 100644 --- a/crates/evm/src/log.rs +++ b/crates/evm/src/log.rs @@ -16,25 +16,9 @@ use alloy_primitives::{Address, U256}; use alloy_sol_types::{sol, SolEvent}; -use arc_precompiles::NATIVE_COIN_AUTHORITY_ADDRESS; use reth_ethereum::primitives::Log; use revm::handler::SYSTEM_ADDRESS; -sol! { - #[derive(Debug, PartialEq, Eq)] - event NativeCoinTransferred(address indexed from, address indexed to, uint256 amount); -} - -// Creates a log for native coin transfers -pub(crate) fn create_native_transfer_log(from: Address, to: Address, amount: U256) -> Log { - let log_data = NativeCoinTransferred { from, to, amount }.encode_log_data(); - - Log { - address: NATIVE_COIN_AUTHORITY_ADDRESS, - data: log_data, - } -} - sol! { #[derive(Debug, PartialEq, Eq)] event Transfer(address indexed from, address indexed to, uint256 amount); diff --git a/crates/evm/src/opcode.rs b/crates/evm/src/opcode.rs index f263e09d..830cb675 100644 --- a/crates/evm/src/opcode.rs +++ b/crates/evm/src/opcode.rs @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::log::{create_eip7708_transfer_log, create_native_transfer_log}; +use crate::log::create_eip7708_transfer_log; use alloy_evm::eth::EthEvmContext; use arc_execution_config::native_coin_control::{ compute_is_blocklisted_storage_slot, is_blocklisted_status, @@ -23,23 +23,10 @@ use arc_precompiles::helpers::{ self, ERR_BLOCKED_ADDRESS, ERR_SELFDESTRUCTED_BALANCE_INCREASED, ERR_ZERO_ADDRESS, }; use arc_precompiles::native_coin_control; -use revm_context_interface::{ContextTr, JournalTr}; - -/// Controls which transfer log is emitted by the SELFDESTRUCT instruction. -enum TransferLogMode { - /// Emit the custom NativeCoinTransferred log (pre-Zero5). - NativeCoinTransferred, - /// Emit an EIP-7708 ERC-20 Transfer log (Zero5+). - Eip7708Transfer, -} - -#[derive(Clone, Copy)] -enum BlocklistReadPolicy { - /// SLOAD failures are treated as "not blocklisted" for backward compatibility. - FailOpen, - /// SLOAD failures propagate to the caller; an unexpected cold NCC account is loaded and retried. - FailClosed, -} +use revm_context_interface::{ + journaled_state::{account::JournaledAccountTr, JournalLoadError}, + ContextTr, JournalTr, +}; use reth_ethereum::evm::primitives::Database; use reth_evm::revm::{ @@ -57,6 +44,17 @@ use revm::interpreter::interpreter_types::LoopControl; use revm_interpreter::StateLoad; // Overridden SELFDESTRUCT that applies Arc Network-specific functionality +#[derive(Clone, Copy, Debug)] +enum BlocklistReadPolicy { + FailOpen, + FailClosed, +} + +#[derive(Clone, Copy, Debug)] +enum TargetWarmthPolicy { + AccessListOnly, + Transaction, +} // Forked from: https://github.com/bluealloy/revm/blob/v97/crates/interpreter/src/instructions/host.rs#L387, // with the following modifications: @@ -65,9 +63,8 @@ use revm_interpreter::StateLoad; // - Disallow selfdestruct if target == addr, and amount is non-zero fn arc_network_selfdestruct_impl( mut context: InstructionContext<'_, EthEvmContext, WIRE>, - check_target_destructed: bool, - log_mode: Option, blocklist_read_policy: BlocklistReadPolicy, + target_warmth_policy: TargetWarmthPolicy, ) { require_non_staticcall!(context.interpreter); popn!([target], context.interpreter); @@ -81,10 +78,7 @@ fn arc_network_selfdestruct_impl( let addr_balance = context.host.balance(addr); let is_cold = match addr_balance.clone() { Some(balance) if !balance.is_zero() => { - // Zero5: reject SELFDESTRUCT to zero address (prevents burn-like semantics) - if matches!(log_mode, Some(TransferLogMode::Eip7708Transfer)) - && target == alloy_primitives::Address::ZERO - { + if target == alloy_primitives::Address::ZERO { context .interpreter .bytecode @@ -102,8 +96,8 @@ fn arc_network_selfdestruct_impl( addr, target, skip_cold_load, - check_target_destructed, blocklist_read_policy, + target_warmth_policy, ) else { // The next action is set in the check_selfdestruct_accounts. return; @@ -141,19 +135,9 @@ fn arc_network_selfdestruct_impl( // The balance was captured before selfdestruct zeroed it. if let Some(balance) = addr_balance { if !balance.is_zero() { - match log_mode { - Some(TransferLogMode::NativeCoinTransferred) => { - context - .host - .log(create_native_transfer_log(addr, target, balance.data)); - } - Some(TransferLogMode::Eip7708Transfer) => { - context - .host - .log(create_eip7708_transfer_log(addr, target, balance.data)); - } - None => {} - } + context + .host + .log(create_eip7708_transfer_log(addr, target, balance.data)); } } // END MODIFIED CODE @@ -183,39 +167,36 @@ fn arc_network_selfdestruct_impl( context.interpreter.halt(InstructionResult::SelfDestruct); } -/// Pre-Zero5 variant: does not check target destructed status and emits NativeCoinTransferred logs. -pub(crate) fn arc_network_selfdestruct_zero4( +/// Pre-Zero7 SELFDESTRUCT handler with fail-open blocklist reads. +pub(crate) fn arc_network_selfdestruct( context: InstructionContext<'_, EthEvmContext, WIRE>, ) { arc_network_selfdestruct_impl( context, - false, - Some(TransferLogMode::NativeCoinTransferred), BlocklistReadPolicy::FailOpen, + TargetWarmthPolicy::AccessListOnly, ); } -/// Zero5 and Zero6: checks target destructed status and emits EIP-7708 Transfer logs. -pub(crate) fn arc_network_selfdestruct_zero5( +/// Zero7+ SELFDESTRUCT handler with fail-closed blocklist reads. +pub(crate) fn arc_network_selfdestruct_zero7( context: InstructionContext<'_, EthEvmContext, WIRE>, ) { arc_network_selfdestruct_impl( context, - true, - Some(TransferLogMode::Eip7708Transfer), - BlocklistReadPolicy::FailOpen, + BlocklistReadPolicy::FailClosed, + TargetWarmthPolicy::AccessListOnly, ); } -/// Zero7+: fail closed on blocklist read failures. -pub(crate) fn arc_network_selfdestruct_zero7( +/// Zero8+ SELFDESTRUCT handler with transaction-warm target checks. +pub(crate) fn arc_network_selfdestruct_zero8( context: InstructionContext<'_, EthEvmContext, WIRE>, ) { arc_network_selfdestruct_impl( context, - true, - Some(TransferLogMode::Eip7708Transfer), BlocklistReadPolicy::FailClosed, + TargetWarmthPolicy::Transaction, ); } @@ -250,10 +231,8 @@ fn is_blocklisted( }; debug_assert!( !native_coin_control_was_cold, - "NativeCoinControl should be preloaded before Zero7 SELFDESTRUCT blocklist reads" + "NativeCoinControl should be preloaded before SELFDESTRUCT blocklist reads" ); - // The account is now loaded and warm; the retry should succeed. - // If it doesn't, the `?` propagates the load error to the caller. context.host.sload_skip_cold_load( native_coin_control::NATIVE_COIN_CONTROL_ADDRESS, slot, @@ -284,8 +263,8 @@ fn check_selfdestruct_accounts( source: alloy_primitives::Address, target: alloy_primitives::Address, skip_cold_load: bool, - check_target_destructed: bool, blocklist_read_policy: BlocklistReadPolicy, + target_warmth_policy: TargetWarmthPolicy, ) -> Result, ()> { // Disallow selfdestruct if target == source if source == target { @@ -293,7 +272,6 @@ fn check_selfdestruct_accounts( return Err(()); } - // Check if either account is blocklisted let target_blocklisted = match is_blocklisted(context, target, blocklist_read_policy) { Ok(is_blocklisted) => is_blocklisted, Err(err) => { @@ -342,46 +320,72 @@ fn check_selfdestruct_accounts( return Err(()); } - // Skip the selfdestruct, early return. - if !check_target_destructed { - return Ok(None); - } - - // We cannot call JournalInner here to skip a cold load. - // Additionally, `load_account_mut_skip_cold_load` will panic if a `LoadError::ColdLoadSkipped` occurs. - // Therefore, we use `warm_addresses.check_is_cold` to check the cold status directly. - if context - .host - .journal_mut() - .warm_addresses - .check_is_cold::(&target, skip_cold_load) - .is_err() - { - context.interpreter.halt_oog(); - return Err(()); - } - - // Load target account and check if it is desctructed. - match context.host.journal_mut().load_account(target) { - Ok(acc) => { - if acc.is_selfdestructed() { - context - .interpreter - .bytecode - .set_action(InterpreterAction::new_return( - InstructionResult::Revert, - helpers::revert_message_to_bytes(ERR_SELFDESTRUCTED_BALANCE_INCREASED), - context.interpreter.gas, - )); - return Err(()); + if matches!(target_warmth_policy, TargetWarmthPolicy::Transaction) { + // Load target account and check if it is destructed. + match context + .host + .journal_mut() + .load_account_mut_skip_cold_load(target, skip_cold_load) + { + Ok(acc) => { + if acc.data.account().is_selfdestructed() { + context + .interpreter + .bytecode + .set_action(InterpreterAction::new_return( + InstructionResult::Revert, + helpers::revert_message_to_bytes(ERR_SELFDESTRUCTED_BALANCE_INCREASED), + context.interpreter.gas, + )); + return Err(()); + } + Ok(Some(acc.is_cold)) + } + Err(JournalLoadError::ColdLoadSkipped) => { + context.interpreter.halt_oog(); + Err(()) + } + Err(JournalLoadError::DBError(e)) => { + // Follow the original error handling on Host::selfdestruct for Context, + tracing::error!("load account failed: {:?}", e); + context.interpreter.halt_fatal(); + Err(()) } - Ok(Some(acc.is_cold)) } - Err(e) => { - // Follow the original error handling on Host::selfdestruct for Context, - tracing::error!("load account failed: {:?}", e); - context.interpreter.halt_fatal(); - Err(()) + } else { + if context + .host + .journal_mut() + .warm_addresses + .check_is_cold::(&target, skip_cold_load) + .is_err() + { + context.interpreter.halt_oog(); + return Err(()); + } + + // Load target account and check if it is destructed. + match context.host.journal_mut().load_account(target) { + Ok(acc) => { + if acc.is_selfdestructed() { + context + .interpreter + .bytecode + .set_action(InterpreterAction::new_return( + InstructionResult::Revert, + helpers::revert_message_to_bytes(ERR_SELFDESTRUCTED_BALANCE_INCREASED), + context.interpreter.gas, + )); + return Err(()); + } + Ok(Some(acc.is_cold)) + } + Err(e) => { + // Follow the original error handling on Host::selfdestruct for Context, + tracing::error!("load account failed: {:?}", e); + context.interpreter.halt_fatal(); + Err(()) + } } } } @@ -390,7 +394,6 @@ fn check_selfdestruct_accounts( mod tests { use super::*; use alloy_primitives::{address, Address, U256}; - use alloy_sol_types::SolEvent; use reth_ethereum::evm::revm::db::{EmptyDB, InMemoryDB}; use reth_ethereum::evm::revm::{ context::{Context, ContextTr, JournalTr}, @@ -467,6 +470,13 @@ mod tests { host: EthEvmContext, } + #[derive(Clone, Copy)] + enum SelfdestructHandler { + Legacy, + Zero7, + Zero8, + } + impl HostTestEnv { fn new(db: DB) -> Self { Self::new_with_spec(db, SpecId::PRAGUE) @@ -540,22 +550,15 @@ mod tests { .expect("selfdestruct") } - /// Simulates a call to `arc_network_selfdestruct_impl` for testing. + /// Simulates a call to `arc_network_selfdestruct` for testing. /// Executes SELFDESTRUCT from `account` to `target` using the arc logic, /// Returns the resulting `InterpreterResult`. fn simulate_arc_selfdestruct( &mut self, account: Address, target: Address, - check_target_destructed: bool, ) -> InterpreterResult { - self.simulate_arc_selfdestruct_full( - account, - target, - check_target_destructed, - Some(TransferLogMode::NativeCoinTransferred), - None, - ) + self.simulate_arc_selfdestruct_full(account, target, None) } /// Like `simulate_arc_selfdestruct` but with a custom gas limit. @@ -564,15 +567,30 @@ mod tests { &mut self, account: Address, target: Address, - check_target_destructed: bool, initial_gas_limit: Option, ) -> InterpreterResult { - self.simulate_arc_selfdestruct_full( + self.simulate_arc_selfdestruct_full_with_preload( account, target, - check_target_destructed, - Some(TransferLogMode::NativeCoinTransferred), initial_gas_limit, + true, + SelfdestructHandler::Zero8, + ) + } + + /// Like `simulate_arc_selfdestruct_with_gas`, but uses the Zero7 handler. + fn simulate_arc_selfdestruct_pre_zero8_with_gas( + &mut self, + account: Address, + target: Address, + initial_gas_limit: Option, + ) -> InterpreterResult { + self.simulate_arc_selfdestruct_full_with_preload( + account, + target, + initial_gas_limit, + true, + SelfdestructHandler::Zero7, ) } @@ -581,18 +599,14 @@ mod tests { &mut self, account: Address, target: Address, - check_target_destructed: bool, - log_mode: Option, initial_gas_limit: Option, ) -> InterpreterResult { self.simulate_arc_selfdestruct_full_with_preload( account, target, - check_target_destructed, - log_mode, initial_gas_limit, true, - BlocklistReadPolicy::FailClosed, + SelfdestructHandler::Zero8, ) } @@ -600,30 +614,51 @@ mod tests { &mut self, account: Address, target: Address, - check_target_destructed: bool, - blocklist_read_policy: BlocklistReadPolicy, ) -> InterpreterResult { self.simulate_arc_selfdestruct_full_with_preload( account, target, - check_target_destructed, - Some(TransferLogMode::NativeCoinTransferred), None, false, - blocklist_read_policy, + SelfdestructHandler::Legacy, + ) + } + + fn simulate_arc_selfdestruct_zero7_without_native_coin_control_preload( + &mut self, + account: Address, + target: Address, + ) -> InterpreterResult { + self.simulate_arc_selfdestruct_full_with_preload( + account, + target, + None, + false, + SelfdestructHandler::Zero7, + ) + } + + fn simulate_arc_selfdestruct_zero8_without_native_coin_control_preload( + &mut self, + account: Address, + target: Address, + ) -> InterpreterResult { + self.simulate_arc_selfdestruct_full_with_preload( + account, + target, + None, + false, + SelfdestructHandler::Zero8, ) } - #[allow(clippy::too_many_arguments)] fn simulate_arc_selfdestruct_full_with_preload( &mut self, account: Address, target: Address, - check_target_destructed: bool, - log_mode: Option, initial_gas_limit: Option, preload_native_coin_control: bool, - blocklist_read_policy: BlocklistReadPolicy, + handler: SelfdestructHandler, ) -> InterpreterResult { if preload_native_coin_control { self.host @@ -649,19 +684,18 @@ mod tests { } // Deduct the static cost of selfdestruct first to simulate the full op cost. - assert!(interpreter.gas.record_cost(STATIC_GAS_COST)); + assert!(interpreter.gas.record_regular_cost(STATIC_GAS_COST)); // Prepare context and execute. let context = InstructionContext { interpreter: &mut interpreter, host: &mut self.host, }; - arc_network_selfdestruct_impl( - context, - check_target_destructed, - log_mode, - blocklist_read_policy, - ); + match handler { + SelfdestructHandler::Legacy => arc_network_selfdestruct(context), + SelfdestructHandler::Zero7 => arc_network_selfdestruct_zero7(context), + SelfdestructHandler::Zero8 => arc_network_selfdestruct_zero8(context), + } // The selfdestruct should halt and return a Return action. let next_action = interpreter.take_next_action(); @@ -785,61 +819,29 @@ mod tests { assert_account_matches!(env, TARGET, AccountStatus::Touched, amount); } - #[test] - fn selfdestruct_emits_event_when_balance_non_zero() { - let amount = U256::from(42); - - for check_target_destruct_locally in [true, false] { - let mut env = HostTestEnv::new(EmptyDB::new()); - env.set_account_balance(ACCOUNT, amount); - - let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET, check_target_destruct_locally); - assert_eq!(res.result, InstructionResult::SelfDestruct); - assert_eq!(res.gas.spent(), 32600u64); // 5000 static + 25000 new account + 2600 cold account - assert_eq!(res.gas.refunded(), 0i64); - - // Verify log. - let logs = env.host.journal_mut().take_logs(); - assert_eq!(logs.len(), 1, "exactly one transfer event expected"); - let decoded = - crate::log::NativeCoinTransferred::decode_log(&logs[0]).expect("decode log"); - assert_eq!(decoded.data.from, ACCOUNT); - assert_eq!(decoded.data.to, TARGET); - assert_eq!(decoded.data.amount, amount); - } - } - #[test] fn selfdestruct_no_event_emitted_when_balance_zero() { - for check_target_destruct_locally in [true, false] { - let mut env = HostTestEnv::new(EmptyDB::new()); + let mut env = HostTestEnv::new(EmptyDB::new()); - let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET, check_target_destruct_locally); - assert_eq!(res.result, InstructionResult::SelfDestruct); - assert_eq!(res.gas.spent(), 7600u64); // 5000 static cost + 2600 cold target, no transfer to create new account - assert_eq!(res.gas.refunded(), 0i64); + let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET); + assert_eq!(res.result, InstructionResult::SelfDestruct); + assert_eq!(res.gas.total_gas_spent(), 7600u64); // 5000 static cost + 2600 cold target, no transfer to create new account + assert_eq!(res.gas.refunded(), 0i64); - assert!( - env.host.journal_mut().take_logs().is_empty(), - "no transfer event expected" - ); - } + assert!( + env.host.journal_mut().take_logs().is_empty(), + "no transfer event expected" + ); } - /// Under Zero5, selfdestruct with non-zero balance emits an EIP-7708 Transfer log. + /// Selfdestruct with non-zero balance emits an EIP-7708 Transfer log. #[test] - fn selfdestruct_zero5_emits_eip7708_transfer_log() { + fn selfdestruct_emits_eip7708_transfer_log() { let amount = U256::from(42); let mut env = HostTestEnv::new(EmptyDB::new()); env.set_account_balance(ACCOUNT, amount); - let res = env.simulate_arc_selfdestruct_full( - ACCOUNT, - TARGET, - true, - Some(TransferLogMode::Eip7708Transfer), - None, - ); + let res = env.simulate_arc_selfdestruct_full(ACCOUNT, TARGET, None); assert_eq!(res.result, InstructionResult::SelfDestruct); // Verify EIP-7708 Transfer log was emitted @@ -847,7 +849,7 @@ mod tests { assert_eq!( logs.len(), 1, - "Zero5: exactly one EIP-7708 Transfer log expected from selfdestruct" + "exactly one EIP-7708 Transfer log expected from selfdestruct" ); assert_eq!( logs[0].address, @@ -860,177 +862,334 @@ mod tests { assert_account_matches!(env, TARGET, State::touch_new(), amount); } - /// Zero5: SELFDESTRUCT to Address::ZERO with non-zero balance should revert. + /// SELFDESTRUCT to Address::ZERO with non-zero balance should revert. #[test] - fn selfdestruct_zero5_to_zero_address_reverts() { + fn selfdestruct_to_zero_address_reverts() { let amount = U256::from(42); let mut env = HostTestEnv::new(EmptyDB::new()); env.set_account_balance(ACCOUNT, amount); - let res = env.simulate_arc_selfdestruct_full( - ACCOUNT, - Address::ZERO, - true, - Some(TransferLogMode::Eip7708Transfer), - None, - ); + let res = env.simulate_arc_selfdestruct_full(ACCOUNT, Address::ZERO, None); assert_eq!( res.result, InstructionResult::Revert, - "Zero5: SELFDESTRUCT to zero address should revert" + "SELFDESTRUCT to zero address should revert" ); // Balance should NOT have been transferred (account was touched during setup) assert_account_matches!(env, ACCOUNT, State::touch_new(), amount); } - /// Pre-Zero5: SELFDESTRUCT to Address::ZERO is not blocked (existing behavior). #[test] - fn selfdestruct_pre_zero5_to_zero_address_allowed() { - let amount = U256::from(42); + fn selfdestruct_cold_load_skipped_halts_oog() { + // Gas limit so that after static cost, remaining < cold load cost (2600) → skip_cold_load = true. + // Target is cold (not loaded), so host.selfdestruct returns ColdLoadSkipped. + let initial_gas = STATIC_GAS_COST + 100; + let mut env = HostTestEnv::new(EmptyDB::new()); - env.set_account_balance(ACCOUNT, amount); + env.set_account_balance(ACCOUNT, U256::from(1)); + + let res = env.simulate_arc_selfdestruct_with_gas(ACCOUNT, TARGET, Some(initial_gas)); - let res = env.simulate_arc_selfdestruct_full( - ACCOUNT, - Address::ZERO, - false, - Some(TransferLogMode::NativeCoinTransferred), - None, - ); - // Pre-Zero5 variants use NativeCoinTransferred and don't block zero address assert_eq!( res.result, - InstructionResult::SelfDestruct, - "Pre-Zero5: SELFDESTRUCT to zero address should succeed" + InstructionResult::OutOfGas, + "ColdLoadSkipped from host.selfdestruct should halt with OutOfGas" + ); + assert!( + res.gas.total_gas_spent() <= initial_gas, + "gas spent should not exceed initial limit" ); } + #[cfg_attr( + debug_assertions, + should_panic(expected = "NativeCoinControl should be preloaded") + )] #[test] - fn selfdestruct_cold_load_skipped_halts_oog() { - // Gas limit so that after static cost, remaining < cold load cost (2600) → skip_cold_load = true. - // Target is cold (not loaded), so host.selfdestruct returns ColdLoadSkipped. - let initial_gas = STATIC_GAS_COST + 100; + fn selfdestruct_zero7_recovers_or_debug_asserts_when_native_coin_control_is_not_loaded() { + let mut db = InMemoryDB::default(); + let slot = native_coin_control::compute_is_blocklisted_storage_slot(TARGET); + db.insert_account_storage( + native_coin_control::NATIVE_COIN_CONTROL_ADDRESS, + slot.into(), + U256::from(1), + ) + .expect("insert blocklist storage"); + let mut env = HostTestEnv::new(db); + env.set_account_balance(ACCOUNT, U256::from(1)); + + let res = env + .simulate_arc_selfdestruct_zero7_without_native_coin_control_preload(ACCOUNT, TARGET); + + #[cfg(debug_assertions)] + let _ = res; + + #[cfg(not(debug_assertions))] + { + assert_eq!(res.result, InstructionResult::Revert); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST); + assert_eq!(res.gas.refunded(), 0); + assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::from(1)); + assert_account_matches!(env, TARGET, State::loaded_new(), U256::ZERO); + } + } + + #[test] + fn selfdestruct_pre_zero7_preserves_fail_open_without_native_coin_control_loaded() { + let mut db = InMemoryDB::default(); + let slot = native_coin_control::compute_is_blocklisted_storage_slot(TARGET); + db.insert_account_storage( + native_coin_control::NATIVE_COIN_CONTROL_ADDRESS, + slot.into(), + U256::from(1), + ) + .expect("insert blocklist storage"); + let mut env = HostTestEnv::new(db); + env.set_account_balance(ACCOUNT, U256::from(1)); + + let res = + env.simulate_arc_selfdestruct_without_native_coin_control_preload(ACCOUNT, TARGET); + + assert_eq!(res.result, InstructionResult::SelfDestruct); + assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::ZERO); + assert_account_matches!(env, TARGET, State::touch_new(), U256::from(1)); + } + + /// Database wrapper that forces a storage-read failure for the Native Coin Control + /// blocklist account, while delegating every other access to an inner `InMemoryDB`. + /// + /// This lets the SELFDESTRUCT opcode tests inject a `LoadError::DBError` on the + /// blocklist SLOAD: `basic()` (used to preload/warm the NCC account) is delegated so + /// the account loads cleanly, but `storage()` for `NATIVE_COIN_CONTROL_ADDRESS` errors, + /// which the journal surfaces to the host as `LoadError::DBError`. + #[derive(Debug, thiserror::Error)] + #[error("forced blocklist storage read failure")] + struct ForcedBlocklistReadError; + impl revm::database_interface::DBErrorMarker for ForcedBlocklistReadError {} + + #[derive(Debug)] + struct BlocklistReadFailingDb { + inner: InMemoryDB, + } + + impl BlocklistReadFailingDb { + fn new(inner: InMemoryDB) -> Self { + Self { inner } + } + } + + impl revm::Database for BlocklistReadFailingDb { + type Error = ForcedBlocklistReadError; + + fn basic( + &mut self, + address: Address, + ) -> Result, Self::Error> { + ::basic(&mut self.inner, address) + .map_err(|infallible: core::convert::Infallible| match infallible {}) + } + + fn code_by_hash( + &mut self, + code_hash: alloy_primitives::B256, + ) -> Result { + ::code_by_hash(&mut self.inner, code_hash) + .map_err(|infallible: core::convert::Infallible| match infallible {}) + } + + fn storage( + &mut self, + address: Address, + index: revm_primitives::StorageKey, + ) -> Result { + // Force the failure only for the blocklist account so the test exercises the + // exact SLOAD that `is_blocklisted` performs. + if address == native_coin_control::NATIVE_COIN_CONTROL_ADDRESS { + return Err(ForcedBlocklistReadError); + } + ::storage(&mut self.inner, address, index) + .map_err(|infallible: core::convert::Infallible| match infallible {}) + } + + fn block_hash(&mut self, number: u64) -> Result { + ::block_hash(&mut self.inner, number) + .map_err(|infallible: core::convert::Infallible| match infallible {}) + } + } - for check_target_destruct_locally in [true, false] { - let mut env = HostTestEnv::new(EmptyDB::new()); + /// A blocklist SLOAD that hits a DB error during SELFDESTRUCT must + /// hard-halt under the Zero7 `FailClosed` policy (the error propagates) and must be + /// swallowed under the pre-Zero7 `FailOpen` policy (treated as not-blocklisted, so + /// SELFDESTRUCT proceeds). The DB error is injected via `BlocklistReadFailingDb`, + /// which errors on `storage()` for `NATIVE_COIN_CONTROL_ADDRESS`. + #[test] + fn selfdestruct_blocklist_db_error_fail_closed_halts_fail_open_swallows() { + // FailClosed (Zero7): the blocklist read error must propagate as a fatal halt. + { + let db = BlocklistReadFailingDb::new(InMemoryDB::default()); + let mut env = HostTestEnv::new(db); env.set_account_balance(ACCOUNT, U256::from(1)); - let res = env.simulate_arc_selfdestruct_with_gas( + // args: (account, target, gas, preload_native_coin_control, handler) + // SelfdestructHandler::Zero7 selects the FailClosed handler. + let res = env.simulate_arc_selfdestruct_full_with_preload( ACCOUNT, TARGET, - check_target_destruct_locally, - Some(initial_gas), + None, + true, + SelfdestructHandler::Zero7, ); assert_eq!( res.result, - InstructionResult::OutOfGas, - "ColdLoadSkipped from host.selfdestruct should halt with OutOfGas" + InstructionResult::FatalExternalError, + "Zero7 FailClosed: a blocklist SLOAD DBError must hard-halt (fatal), not be swallowed" ); + // The fatal halt occurs before any balance transfer, so no transfer log. assert!( - res.gas.spent() <= initial_gas, - "gas spent should not exceed initial limit" + env.host.journal_mut().take_logs().is_empty(), + "no transfer log expected when the read fails closed" ); } - } - - #[test] - #[cfg_attr( - debug_assertions, - should_panic(expected = "NativeCoinControl should be preloaded") - )] - fn selfdestruct_zero7_recovers_or_debug_asserts_when_native_coin_control_is_not_loaded() { - let check_target_destruct_locally_values: &[bool] = if cfg!(debug_assertions) { - &[true] - } else { - &[true, false] - }; - for check_target_destruct_locally in check_target_destruct_locally_values { - let mut db = InMemoryDB::default(); - let slot = native_coin_control::compute_is_blocklisted_storage_slot(TARGET); - db.insert_account_storage( - native_coin_control::NATIVE_COIN_CONTROL_ADDRESS, - slot.into(), - U256::from(1), - ) - .expect("insert blocklist storage"); + // FailOpen (pre-Zero7): the same read error is swallowed; SELFDESTRUCT proceeds. + { + let db = BlocklistReadFailingDb::new(InMemoryDB::default()); let mut env = HostTestEnv::new(db); env.set_account_balance(ACCOUNT, U256::from(1)); - let res = env.simulate_arc_selfdestruct_without_native_coin_control_preload( + // SelfdestructHandler::Legacy selects the pre-Zero7 FailOpen handler. + let res = env.simulate_arc_selfdestruct_full_with_preload( ACCOUNT, TARGET, - *check_target_destruct_locally, - BlocklistReadPolicy::FailClosed, + None, + true, + SelfdestructHandler::Legacy, ); - #[cfg(debug_assertions)] - let _ = res; - - #[cfg(not(debug_assertions))] - { - assert_eq!(res.result, InstructionResult::Revert); - assert_eq!(res.gas.spent(), STATIC_GAS_COST); - assert_eq!(res.gas.refunded(), 0); - assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::from(1)); - assert_account_matches!(env, TARGET, State::loaded_new(), U256::ZERO); - } + assert_eq!( + res.result, + InstructionResult::SelfDestruct, + "Pre-Zero7 FailOpen: a blocklist SLOAD DBError must be swallowed so SELFDESTRUCT succeeds" + ); + assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::ZERO); + assert_account_matches!(env, TARGET, State::touch_new(), U256::from(1)); } } + #[cfg_attr( + debug_assertions, + should_panic(expected = "NativeCoinControl should be preloaded") + )] #[test] - fn selfdestruct_pre_zero7_preserves_fail_open_without_native_coin_control_loaded() { - for check_target_destruct_locally in [true, false] { - let mut db = InMemoryDB::default(); - let slot = native_coin_control::compute_is_blocklisted_storage_slot(TARGET); - db.insert_account_storage( - native_coin_control::NATIVE_COIN_CONTROL_ADDRESS, - slot.into(), - U256::from(1), - ) - .expect("insert blocklist storage"); - let mut env = HostTestEnv::new(db); - env.set_account_balance(ACCOUNT, U256::from(1)); + fn selfdestruct_zero8_includes_zero7_fail_closed_blocklist_reads() { + let mut db = InMemoryDB::default(); + let slot = native_coin_control::compute_is_blocklisted_storage_slot(TARGET); + db.insert_account_storage( + native_coin_control::NATIVE_COIN_CONTROL_ADDRESS, + slot.into(), + U256::from(1), + ) + .expect("insert blocklist storage"); + let mut env = HostTestEnv::new(db); + env.set_account_balance(ACCOUNT, U256::from(1)); - let res = env.simulate_arc_selfdestruct_without_native_coin_control_preload( - ACCOUNT, - TARGET, - check_target_destruct_locally, - BlocklistReadPolicy::FailOpen, - ); + let res = env + .simulate_arc_selfdestruct_zero8_without_native_coin_control_preload(ACCOUNT, TARGET); - assert_eq!(res.result, InstructionResult::SelfDestruct); - assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::ZERO); - assert_account_matches!(env, TARGET, State::touch_new(), U256::from(1)); + #[cfg(debug_assertions)] + let _ = res; + + #[cfg(not(debug_assertions))] + { + assert_eq!(res.result, InstructionResult::Revert); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST); + assert_eq!(res.gas.refunded(), 0); + assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::from(1)); + assert_account_matches!(env, TARGET, State::loaded_new(), U256::ZERO); } } + #[test] + fn selfdestruct_to_transaction_warm_target_with_low_gas_succeeds() { + // Gas limit so that after static cost, remaining < cold load cost (2600), so skip_cold_load = true. + // TARGET was warmed earlier in this transaction, so SELFDESTRUCT must not require a cold load. + let initial_gas = STATIC_GAS_COST + 100; + + let mut env = HostTestEnv::new(EmptyDB::new()); + env.set_account_balance(ACCOUNT, U256::from(1)); + env.set_account_balance(TARGET, U256::from(1)); + + let res = env.simulate_arc_selfdestruct_with_gas(ACCOUNT, TARGET, Some(initial_gas)); + + assert_eq!( + res.result, + InstructionResult::SelfDestruct, + "transaction-warm target should not require cold selfdestruct gas" + ); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST); + } + + #[test] + fn selfdestruct_to_transaction_warm_target_before_zero8_halts_oog() { + // Same setup as the Zero8 behavior, but the legacy handler only consults + // warm_addresses and therefore still treats TARGET as cold. + let initial_gas = STATIC_GAS_COST + 100; + + let mut env = HostTestEnv::new(EmptyDB::new()); + env.set_account_balance(ACCOUNT, U256::from(1)); + env.set_account_balance(TARGET, U256::from(1)); + + let res = + env.simulate_arc_selfdestruct_pre_zero8_with_gas(ACCOUNT, TARGET, Some(initial_gas)); + + assert_eq!( + res.result, + InstructionResult::OutOfGas, + "pre-Zero8 SELFDESTRUCT should not use transaction-warm target state" + ); + assert!( + res.gas.total_gas_spent() <= initial_gas, + "gas spent should not exceed initial limit" + ); + } + + #[test] + fn selfdestruct_to_transaction_warm_target_before_zero8_preserves_legacy_warm_charge() { + let initial_gas = STATIC_GAS_COST + 2600; + + let mut env = HostTestEnv::new(EmptyDB::new()); + env.set_account_balance(ACCOUNT, U256::from(1)); + env.set_account_balance(TARGET, U256::from(1)); + + let res = + env.simulate_arc_selfdestruct_pre_zero8_with_gas(ACCOUNT, TARGET, Some(initial_gas)); + + assert_eq!(res.result, InstructionResult::SelfDestruct); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST); + } + // Regression tests against existing behavior #[test] fn selfdestruct_refund_recorded_pre_london() { - for check_target_destruct_locally in [true, false] { - let mut env = HostTestEnv::new_with_spec(EmptyDB::new(), SpecId::ISTANBUL); + let mut env = HostTestEnv::new_with_spec(EmptyDB::new(), SpecId::ISTANBUL); - let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET, check_target_destruct_locally); - assert_eq!(res.result, InstructionResult::SelfDestruct); - assert_eq!(res.gas.spent(), STATIC_GAS_COST); - assert_eq!(res.gas.refunded(), gas::SELFDESTRUCT_REFUND); - } + let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET); + assert_eq!(res.result, InstructionResult::SelfDestruct); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST); + assert_eq!(res.gas.refunded(), gas::SELFDESTRUCT_REFUND); } #[test] fn selfdestruct_no_refund_after_london() { - for check_target_destruct_locally in [true, false] { - let mut env = HostTestEnv::new_with_spec(EmptyDB::new(), SpecId::LONDON); + let mut env = HostTestEnv::new_with_spec(EmptyDB::new(), SpecId::LONDON); - let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET, check_target_destruct_locally); - assert_eq!(res.result, InstructionResult::SelfDestruct); - assert_eq!(res.gas.spent(), 7600u64); // 5000 static cost + 2600 cold target - assert_eq!(res.gas.refunded(), 0i64); - } + let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET); + assert_eq!(res.result, InstructionResult::SelfDestruct); + assert_eq!(res.gas.total_gas_spent(), 7600u64); // 5000 static cost + 2600 cold target + assert_eq!(res.gas.refunded(), 0i64); } #[test] @@ -1038,48 +1197,43 @@ mod tests { let initial_account_balance = U256::from(123u64); let initial_target_balance = U256::from(456u64); - for check_target_destruct_locally in [true, false] { - let mut env = HostTestEnv::new(EmptyDB::new()); + let mut env = HostTestEnv::new(EmptyDB::new()); - // Set balances - env.set_account_balance(ACCOUNT, initial_account_balance); - env.set_account_balance(TARGET, initial_target_balance); + // Set balances + env.set_account_balance(ACCOUNT, initial_account_balance); + env.set_account_balance(TARGET, initial_target_balance); - let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET, check_target_destruct_locally); - assert_eq!(res.result, InstructionResult::SelfDestruct); - assert_eq!(res.gas.spent(), STATIC_GAS_COST); - assert_eq!(res.gas.refunded(), 0i64); + let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET); + assert_eq!(res.result, InstructionResult::SelfDestruct); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST); + assert_eq!(res.gas.refunded(), 0i64); - // Verify balances - assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::ZERO); - assert_account_matches!( - env, - TARGET, - State::touch_new(), - initial_target_balance + initial_account_balance - ); - } + // Verify balances + assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::ZERO); + assert_account_matches!( + env, + TARGET, + State::touch_new(), + initial_target_balance + initial_account_balance + ); } #[test] fn selfdestruct_rejects_transfers_to_self() { let initial_account_balance = U256::from(123u64); - for check_target_destruct_locally in [true, false] { - let mut env = HostTestEnv::new(EmptyDB::new()); + let mut env = HostTestEnv::new(EmptyDB::new()); - // Set balances - env.set_account_balance(ACCOUNT, initial_account_balance); + // Set balances + env.set_account_balance(ACCOUNT, initial_account_balance); - let res = - env.simulate_arc_selfdestruct(ACCOUNT, ACCOUNT, check_target_destruct_locally); - assert_eq!(res.result, InstructionResult::Revert); - assert_eq!(res.gas.spent(), STATIC_GAS_COST); - assert_eq!(res.gas.refunded(), 0i64); + let res = env.simulate_arc_selfdestruct(ACCOUNT, ACCOUNT); + assert_eq!(res.result, InstructionResult::Revert); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST); + assert_eq!(res.gas.refunded(), 0i64); - // Verify balance is unchanged - assert_account_matches!(env, ACCOUNT, State::touch_new(), initial_account_balance); - } + // Verify balance is unchanged + assert_account_matches!(env, ACCOUNT, State::touch_new(), initial_account_balance); } #[test] @@ -1087,29 +1241,27 @@ mod tests { let initial_account_balance = U256::from(456u64); let initial_target_balance = U256::from(789u64); - for check_target_destruct_locally in [true, false] { - let mut env = HostTestEnv::new(EmptyDB::new()); + let mut env = HostTestEnv::new(EmptyDB::new()); - // Set balances - env.set_account_balance(ACCOUNT, initial_account_balance); - env.set_account_balance(TARGET, initial_target_balance); + // Set balances + env.set_account_balance(ACCOUNT, initial_account_balance); + env.set_account_balance(TARGET, initial_target_balance); - // Blocklist the target address - env.set_blocklist(TARGET); + // Blocklist the target address + env.set_blocklist(TARGET); - let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET, check_target_destruct_locally); - assert_eq!( - res.result, - InstructionResult::Revert, - "Selfdestruct should revert when target is blocklisted" - ); - assert_eq!(res.gas.spent(), STATIC_GAS_COST); - assert_eq!(res.gas.refunded(), 0i64); + let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET); + assert_eq!( + res.result, + InstructionResult::Revert, + "Selfdestruct should revert when target is blocklisted" + ); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST); + assert_eq!(res.gas.refunded(), 0i64); - // Verify balances are unchanged - assert_account_matches!(env, ACCOUNT, State::touch_new(), initial_account_balance); - assert_account_matches!(env, TARGET, State::touch_new(), initial_target_balance); - } + // Verify balances are unchanged + assert_account_matches!(env, ACCOUNT, State::touch_new(), initial_account_balance); + assert_account_matches!(env, TARGET, State::touch_new(), initial_target_balance); } #[test] @@ -1117,32 +1269,30 @@ mod tests { let initial_account_balance = U256::from(321u64); let initial_target_balance = U256::from(654u64); - for check_target_destruct_locally in [true, false] { - let mut env = HostTestEnv::new(EmptyDB::new()); + let mut env = HostTestEnv::new(EmptyDB::new()); - // Set balances - env.set_account_balance(ACCOUNT, initial_account_balance); - env.set_account_balance(TARGET, initial_target_balance); + // Set balances + env.set_account_balance(ACCOUNT, initial_account_balance); + env.set_account_balance(TARGET, initial_target_balance); - // Blocklist the selfdestructing address (ACCOUNT) - env.set_blocklist(ACCOUNT); + // Blocklist the selfdestructing address (ACCOUNT) + env.set_blocklist(ACCOUNT); - // Run interpreter - let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET, check_target_destruct_locally); + // Run interpreter + let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET); - // Verify the operation was reverted by checking the action set on bytecode - assert_eq!( - res.result, - InstructionResult::Revert, - "Selfdestruct should revert when selfdestructing address is blocklisted" - ); - assert_eq!(res.gas.spent(), STATIC_GAS_COST); - assert_eq!(res.gas.refunded(), 0i64); + // Verify the operation was reverted by checking the action set on bytecode + assert_eq!( + res.result, + InstructionResult::Revert, + "Selfdestruct should revert when selfdestructing address is blocklisted" + ); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST); + assert_eq!(res.gas.refunded(), 0i64); - // Verify balances are unchanged - assert_account_matches!(env, ACCOUNT, State::touch_new(), initial_account_balance); - assert_account_matches!(env, TARGET, State::touch_new(), initial_target_balance); - } + // Verify balances are unchanged + assert_account_matches!(env, ACCOUNT, State::touch_new(), initial_account_balance); + assert_account_matches!(env, TARGET, State::touch_new(), initial_target_balance); } // transfer to a destructed account @@ -1151,32 +1301,29 @@ mod tests { fn selfdestruct_transfer_to_not_destructed_account() { let amount = U256::from(234); - for check_target_destruct_locally in [true, false] { - // Prepare host context with caller balance. - let mut env = HostTestEnv::new(EmptyDB::new()); - env.set_account_balance(ACCOUNT, amount); - - // Destruct `ACCOUNT`, transfer to empty, cold account `TARGET` - // After Cancun, the account will not be destructed if it was not created on the same transaction. - let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET, check_target_destruct_locally); - assert_eq!(res.result, InstructionResult::SelfDestruct); - assert_eq!(res.gas.spent(), 32600u64); - assert_eq!(res.gas.refunded(), 0i64); - assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::ZERO); - assert_account_matches!(env, TARGET, State::touch_new(), amount); - - // Destruct `TARGET`, transfer to warm, not destructed account `ACCOUNT` - let res = env.simulate_arc_selfdestruct(TARGET, ACCOUNT, check_target_destruct_locally); - assert_eq!(res.result, InstructionResult::SelfDestruct); - assert_eq!(res.gas.spent(), 30000u64); // // 5000 static + 25000 new account, warm target - assert_eq!(res.gas.refunded(), 0i64); - assert_account_matches!(env, ACCOUNT, State::touch_new(), amount); - assert_account_matches!(env, TARGET, State::touch_new(), U256::ZERO); - } + // Prepare host context with caller balance. + let mut env = HostTestEnv::new(EmptyDB::new()); + env.set_account_balance(ACCOUNT, amount); + + // Destruct `ACCOUNT`, transfer to empty, cold account `TARGET` + // After Cancun, the account will not be destructed if it was not created on the same transaction. + let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET); + assert_eq!(res.result, InstructionResult::SelfDestruct); + assert_eq!(res.gas.total_gas_spent(), 32600u64); + assert_eq!(res.gas.refunded(), 0i64); + assert_account_matches!(env, ACCOUNT, State::touch_new(), U256::ZERO); + assert_account_matches!(env, TARGET, State::touch_new(), amount); + + // Destruct `TARGET`, transfer to warm, not destructed account `ACCOUNT` + let res = env.simulate_arc_selfdestruct(TARGET, ACCOUNT); + assert_eq!(res.result, InstructionResult::SelfDestruct); + assert_eq!(res.gas.total_gas_spent(), 30000u64); // // 5000 static + 25000 new account, warm target + assert_eq!(res.gas.refunded(), 0i64); + assert_account_matches!(env, ACCOUNT, State::touch_new(), amount); + assert_account_matches!(env, TARGET, State::touch_new(), U256::ZERO); } struct TransferToDestructedTestCase { - check_target_destruct_locally: bool, locally: bool, amount: U256, expect_state_after_selfdestructed: (AccountStatus, AccountStatus), @@ -1185,7 +1332,7 @@ mod tests { impl TransferToDestructedTestCase { fn expect_revert(&self) -> bool { - self.check_target_destruct_locally && !self.amount.is_zero() + !self.amount.is_zero() } } @@ -1195,17 +1342,6 @@ mod tests { let test_cases = [ TransferToDestructedTestCase { - check_target_destruct_locally: true, - locally: true, - amount: U256::ZERO, - expect_state_after_selfdestructed: ( - State::touch_new(), - State::touch_new_destructed(), - ), - expect_state_after_committed: (State::loaded(), State::loaded_new()), - }, - TransferToDestructedTestCase { - check_target_destruct_locally: false, locally: true, amount: U256::ZERO, expect_state_after_selfdestructed: ( @@ -1215,17 +1351,6 @@ mod tests { expect_state_after_committed: (State::loaded(), State::loaded_new()), }, TransferToDestructedTestCase { - check_target_destruct_locally: true, - locally: true, - amount: U256::from(234), - expect_state_after_selfdestructed: ( - State::touch_new(), - State::touch_new_destructed(), - ), - expect_state_after_committed: (State::loaded(), State::loaded_new()), - }, - TransferToDestructedTestCase { - check_target_destruct_locally: false, locally: true, amount: U256::from(234), expect_state_after_selfdestructed: ( @@ -1235,7 +1360,6 @@ mod tests { expect_state_after_committed: (State::loaded(), State::loaded_new()), }, TransferToDestructedTestCase { - check_target_destruct_locally: true, locally: false, amount: U256::ZERO, expect_state_after_selfdestructed: ( @@ -1245,27 +1369,6 @@ mod tests { expect_state_after_committed: (State::loaded(), State::loaded_new()), }, TransferToDestructedTestCase { - check_target_destruct_locally: false, - locally: false, - amount: U256::ZERO, - expect_state_after_selfdestructed: ( - State::touch_new(), - State::destructed_new_before(), - ), - expect_state_after_committed: (State::loaded(), State::loaded_new()), - }, - TransferToDestructedTestCase { - check_target_destruct_locally: true, - locally: false, - amount: U256::from(234), - expect_state_after_selfdestructed: ( - State::touch_new(), - State::destructed_new_before(), - ), - expect_state_after_committed: (State::loaded(), State::loaded_new()), - }, - TransferToDestructedTestCase { - check_target_destruct_locally: false, locally: false, amount: U256::from(234), expect_state_after_selfdestructed: ( @@ -1278,8 +1381,8 @@ mod tests { for (index, tc) in test_cases.iter().enumerate() { let desc = format!( - "index={index}, check_target_destruct_locally={}, locally={}, amount={}", - tc.check_target_destruct_locally, tc.locally, tc.amount + "index={index}, locally={}, amount={}", + tc.locally, tc.amount ); let mut env = HostTestEnv::new(InMemoryDB::default()); @@ -1287,8 +1390,7 @@ mod tests { env.set_account_balance(sender, tc.amount); env.simulate_create_account(sender, TARGET, tc.amount); assert_account_matches!(env, sender, State::touch_new(), U256::ZERO, "({desc})"); - let res = - env.simulate_arc_selfdestruct(TARGET, ACCOUNT, tc.check_target_destruct_locally); + let res = env.simulate_arc_selfdestruct(TARGET, ACCOUNT); assert_eq!(res.result, InstructionResult::SelfDestruct, "({desc})"); assert_account_matches!( env, @@ -1305,8 +1407,7 @@ mod tests { } // Destruct ACCOUNT, transfer balance to local destructed account `TARGET` - let res = - env.simulate_arc_selfdestruct(ACCOUNT, TARGET, tc.check_target_destruct_locally); + let res = env.simulate_arc_selfdestruct(ACCOUNT, TARGET); let (account_amount, target_amount) = if tc.expect_revert() { assert_eq!( @@ -1314,7 +1415,7 @@ mod tests { InstructionResult::Revert, "({desc}) not reverted", ); - assert_eq!(res.gas.spent(), STATIC_GAS_COST, "({desc})"); + assert_eq!(res.gas.total_gas_spent(), STATIC_GAS_COST, "({desc})"); assert_eq!(res.gas.refunded(), 0i64, "({desc})"); (tc.amount, U256::ZERO) } else { @@ -1371,29 +1472,23 @@ mod tests { // account B's selfdestruct in the same transaction). Arc does not implement delayed burn // logs because its restrictions prevent the scenarios that would trigger them: // 1. SELFDESTRUCT to self is always rejected (revert) - // 2. SELFDESTRUCT to Address::ZERO is rejected under Zero5 + // 2. SELFDESTRUCT to Address::ZERO is rejected // 3. SELFDESTRUCT to an already-destructed target is rejected - // These tests verify each restriction holds under Zero5's EIP-7708 log mode. + // These tests verify each restriction holds under baseline EIP-7708 log mode. - /// Zero5: SELFDESTRUCT to self is rejected, so a self-destructed account cannot + /// SELFDESTRUCT to self is rejected, so a self-destructed account cannot /// re-receive its own balance (the primary delayed burn scenario). #[test] - fn selfdestruct_zero5_to_self_rejected() { + fn selfdestruct_to_self_rejected() { let amount = U256::from(100); let mut env = HostTestEnv::new(EmptyDB::new()); env.set_account_balance(ACCOUNT, amount); - let res = env.simulate_arc_selfdestruct_full( - ACCOUNT, - ACCOUNT, - true, - Some(TransferLogMode::Eip7708Transfer), - None, - ); + let res = env.simulate_arc_selfdestruct_full(ACCOUNT, ACCOUNT, None); assert_eq!( res.result, InstructionResult::Revert, - "Zero5: SELFDESTRUCT to self should revert" + "SELFDESTRUCT to self should revert" ); // Balance unchanged — no state modification @@ -1401,16 +1496,13 @@ mod tests { // No logs emitted let logs = env.host.journal_mut().take_logs(); - assert!( - logs.is_empty(), - "Zero5: SELFDESTRUCT to self should emit no logs" - ); + assert!(logs.is_empty(), "SELFDESTRUCT to self should emit no logs"); } - /// Zero5: SELFDESTRUCT to an already-destructed target is rejected, preventing + /// SELFDESTRUCT to an already-destructed target is rejected, preventing /// cross-selfdestruct balance accumulation that would require delayed burn logs. #[test] - fn selfdestruct_zero5_to_destructed_target_rejected() { + fn selfdestruct_to_destructed_target_rejected() { let amount_a = U256::from(100); let amount_b = U256::from(200); let mut env = HostTestEnv::new(EmptyDB::new()); @@ -1422,13 +1514,7 @@ mod tests { env.set_account_balance(TARGET, amount_b); // First: ACCOUNT selfdestructs to TARGET (succeeds) - let res1 = env.simulate_arc_selfdestruct_full( - ACCOUNT, - TARGET, - true, - Some(TransferLogMode::Eip7708Transfer), - None, - ); + let res1 = env.simulate_arc_selfdestruct_full(ACCOUNT, TARGET, None); assert_eq!( res1.result, InstructionResult::SelfDestruct, @@ -1438,46 +1524,34 @@ mod tests { // TARGET now has balance: amount_b + amount_a // TARGET selfdestructs back to ACCOUNT — but ACCOUNT is now destructed. // Arc's check_selfdestruct_accounts rejects this. - let res2 = env.simulate_arc_selfdestruct_full( - TARGET, - ACCOUNT, - true, // check_target_destructed = true (Zero5+ behavior) - Some(TransferLogMode::Eip7708Transfer), - None, - ); + let res2 = env.simulate_arc_selfdestruct_full(TARGET, ACCOUNT, None); assert_eq!( res2.result, InstructionResult::Revert, - "Zero5: SELFDESTRUCT to already-destructed target should revert" + "SELFDESTRUCT to already-destructed target should revert" ); } - /// Zero5: SELFDESTRUCT to Address::ZERO with zero balance succeeds but produces no log. + /// SELFDESTRUCT to Address::ZERO with zero balance succeeds but produces no log. /// (The zero-address check only triggers when balance is non-zero.) #[test] - fn selfdestruct_zero5_to_zero_address_zero_balance_succeeds() { + fn selfdestruct_to_zero_address_zero_balance_succeeds() { let mut env = HostTestEnv::new(EmptyDB::new()); // ACCOUNT has zero balance env.set_account_balance(ACCOUNT, U256::ZERO); - let res = env.simulate_arc_selfdestruct_full( - ACCOUNT, - Address::ZERO, - true, - Some(TransferLogMode::Eip7708Transfer), - None, - ); + let res = env.simulate_arc_selfdestruct_full(ACCOUNT, Address::ZERO, None); // Zero balance → the non-zero branch is skipped entirely → proceeds to host.selfdestruct assert_eq!( res.result, InstructionResult::SelfDestruct, - "Zero5: SELFDESTRUCT to zero address with zero balance should succeed" + "SELFDESTRUCT to zero address with zero balance should succeed" ); let logs = env.host.journal_mut().take_logs(); assert!( logs.is_empty(), - "Zero5: SELFDESTRUCT with zero balance should emit no logs" + "SELFDESTRUCT with zero balance should emit no logs" ); } } diff --git a/crates/evm/src/subcall.rs b/crates/evm/src/subcall.rs index ccea5487..fbf7ea35 100644 --- a/crates/evm/src/subcall.rs +++ b/crates/evm/src/subcall.rs @@ -131,3 +131,27 @@ impl std::fmt::Debug for SubcallRegistry { .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + + #[test] + fn allowed_callers_unrestricted_allows_any_address() { + let callers = AllowedCallers::Unrestricted; + assert!(callers.is_allowed(&address!("0000000000000000000000000000000000000001"))); + assert!(callers.is_allowed(&Address::ZERO)); + } + + #[test] + fn allowed_callers_only_allows_listed_rejects_others() { + let allowed = address!("1800000000000000000000000000000000000005"); + let callers = AllowedCallers::Only(HashSet::from([allowed])); + assert!(callers.is_allowed(&allowed)); + assert!(!callers.is_allowed(&address!("dead00000000000000000000000000000000beef"))); + // Empty allowlist rejects every caller. + let empty = AllowedCallers::Only(HashSet::new()); + assert!(!empty.is_allowed(&allowed)); + } +} diff --git a/crates/evm/src/subcall_test.rs b/crates/evm/src/subcall_test.rs index ff6684ed..7e4f534d 100644 --- a/crates/evm/src/subcall_test.rs +++ b/crates/evm/src/subcall_test.rs @@ -22,12 +22,13 @@ //! //! Only available under `#[cfg(test)]`. -use alloy_primitives::{address, Address, Bytes}; +use alloy_primitives::{address, Address, Bytes, B256}; use alloy_sol_types::{sol_data, SolType, SolValue}; use arc_precompiles::subcall::{ SubcallCompletionResult, SubcallContinuationData, SubcallError, SubcallInitResult, SubcallPrecompile, }; +use revm::bytecode::Bytecode; use revm::handler::FrameResult; use revm::interpreter::interpreter_action::{CallInput, CallInputs, CallScheme, CallValue}; use revm::interpreter::Gas; @@ -65,13 +66,14 @@ impl SubcallPrecompile for SubcallTestPrecompile { scheme: CallScheme::Call, target_address: target, bytecode_address: target, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), value: CallValue::Transfer(alloy_primitives::U256::ZERO), input: CallInput::Bytes(calldata), gas_limit: child_gas.remaining(), is_static: inputs.is_static, caller: inputs.caller, return_memory_offset: 0..0, + reservoir: 0, }); Ok(SubcallInitResult { @@ -205,13 +207,14 @@ impl SubcallPrecompile for FailingCompleteSubcallPrecompile { scheme: CallScheme::Call, target_address: target, bytecode_address: target, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), value: CallValue::Transfer(alloy_primitives::U256::ZERO), input: CallInput::Bytes(calldata), gas_limit: 50_000, is_static: false, caller: inputs.caller, return_memory_offset: 0..0, + reservoir: 0, }), continuation_data: SubcallContinuationData { state: Box::new(()), @@ -264,13 +267,14 @@ impl SubcallPrecompile for RejectingCompleteSubcallPrecompile { scheme: CallScheme::Call, target_address: target, bytecode_address: target, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), value: CallValue::Transfer(alloy_primitives::U256::ZERO), input: CallInput::Bytes(calldata), gas_limit: 50_000, is_static: false, caller: inputs.caller, return_memory_offset: 0..0, + reservoir: 0, }), continuation_data: SubcallContinuationData { state: Box::new(()), diff --git a/crates/execution-config/src/addresses_denylist.rs b/crates/execution-config/src/addresses_denylist.rs index 365726e1..a7d22cfd 100644 --- a/crates/execution-config/src/addresses_denylist.rs +++ b/crates/execution-config/src/addresses_denylist.rs @@ -21,23 +21,27 @@ use alloy_primitives::{address, b256, Address, B256}; use itertools::Itertools; -use thiserror::Error; /// Revert message when a transaction involves a denylisted address. pub const ERR_DENYLISTED_ADDRESS: &str = "Address is denylisted"; -/// Default Denylist contract address when deployed in genesis (localdev). -/// Matches the deterministic address used by the genesis builder (`scripts/genesis/addresses.ts`). -/// Node CLI can use this as the default for `--arc.denylist.address` when the contract is deployed in genesis. +/// Denylist proxy address on localdev, sourced from the genesis builder +/// (`scripts/genesis/addresses.ts`, `denylistAddressByNetwork.localdev`). /// -/// Address derived via deterministic CREATE2 salt search: cast create2 with --seed keccak256("Denylist.v1"), -/// first match with prefix 0x360. Reproduce: `make mine-denylist-salt INIT_CODE_HASH=` -pub const DEFAULT_DENYLIST_ADDRESS: Address = +/// Unlike the other networks, this is a mined CREATE2 address rather than a fixed +/// system-contract slot, so it changes whenever the Denylist init-code changes and the +/// localdev genesis is regenerated. Derived via deterministic CREATE2 salt search: cast +/// create2 with --seed keccak256("Denylist.v1"), first match with prefix 0x360. +/// Reproduce: `make mine-denylist-salt INIT_CODE_HASH=`. +/// +/// Deployed networks hardcode their addresses in `chainspec.rs` instead; resolution for all +/// networks goes through [`ArcChainSpec::denylist_address`](crate::chainspec::ArcChainSpec::denylist_address). +pub const DENYLIST_ADDRESS_LOCALDEV: Address = address!("0x36059b615370eB999e8eC0c9401835B407834221"); /// ERC-7201 base storage slot for the Denylist contract (arc.storage.Denylist.v1). /// Matches the slot used by the genesis builder (`scripts/genesis/Denylist.ts`). -/// Node CLI can use this as the default for `--arc.denylist.storage-slot` when the contract is deployed in genesis. +/// Namespace-derived, so it is identical on every network. pub const DEFAULT_DENYLIST_ERC7201_BASE_SLOT: B256 = b256!("0x1d7e1388d3ae56f3d9c18b1ce8d2b3b1a238a0edf682d2053af5d8a1d2f12f00"); @@ -53,77 +57,62 @@ pub fn compute_denylist_storage_slot(address: Address, base_slot: B256) -> B256 B256::from(keccak256(encoded.as_slice()).0) } -/// Error when building [`AddressesDenylistConfig`] with `enabled` but missing address or slot. -#[derive(Debug, Error)] -pub enum AddressesDenylistConfigError { - #[error("denylist is enabled but address is not set")] - MissingContractAddress, - #[error("denylist is enabled but storage slot is not set")] - MissingStorageSlot, -} - /// Configuration for the addresses denylist. /// -/// Invalid states (enabled without address or slot) are unrepresentable. -#[derive(Default, Debug, Clone)] -pub enum AddressesDenylistConfig { - /// Denylist checks disabled. - #[default] - Disabled, - /// Denylist checks enabled. All fields required. - Enabled { - /// Denylist contract address. - contract_address: Address, - /// ERC-7201 base storage slot for the denylist. - storage_slot: B256, - /// Addresses to exclude from denylist checks (e.g. ops recovery). - /// Stored deduplicated for fast lookup. - addresses_exclusions: Vec
, - }, +/// There is no "denylist off" state. The denylist is a protocol requirement, so every running +/// node has one; a chain spec Arc does not recognise is rejected at startup rather than run +/// without denylist checks. Fields are private so [`AddressesDenylistConfig::new`] is the +/// only way to build one, which keeps the exclusions deduplicated. +#[derive(Debug, Clone)] +pub struct AddressesDenylistConfig { + /// Denylist contract address. + contract_address: Address, + /// ERC-7201 base storage slot for the denylist. + storage_slot: B256, + /// Addresses to exclude from denylist checks (e.g. ops recovery). + /// Stored deduplicated for fast lookup. + addresses_exclusions: Vec
, } impl AddressesDenylistConfig { - /// Build config. When `enabled` is true, `contract_address` and `storage_slot` must be set. - /// Deduplicates exclusions. - pub fn try_new( - enabled: bool, - contract_address: Option
, - storage_slot: Option, + /// Build a denylist config. Deduplicates exclusions. + /// + /// Infallible: the contract address and storage slot are resolved from the chain spec rather + /// than operator input, so there is no unconfigured state to reject. + pub fn new( + contract_address: Address, + storage_slot: B256, addresses_exclusions: Vec
, - ) -> Result { - if enabled { - let contract_address = - contract_address.ok_or(AddressesDenylistConfigError::MissingContractAddress)?; - let storage_slot = - storage_slot.ok_or(AddressesDenylistConfigError::MissingStorageSlot)?; - let addresses_exclusions = addresses_exclusions.into_iter().unique().collect(); - Ok(Self::Enabled { - contract_address, - storage_slot, - addresses_exclusions, - }) - } else { - Ok(Self::Disabled) + ) -> Self { + Self { + contract_address, + storage_slot, + addresses_exclusions: addresses_exclusions.into_iter().unique().collect(), } } - /// Returns true if denylist checks are enabled. + /// Denylist contract address to read denylist storage from. + #[inline] + pub fn contract_address(&self) -> Address { + self.contract_address + } + + /// ERC-7201 base storage slot for the denylist mapping. #[inline] - pub fn is_enabled(&self) -> bool { - matches!(self, Self::Enabled { .. }) + pub fn storage_slot(&self) -> B256 { + self.storage_slot } /// Returns true if the given address is in the address exclusions set. - /// When disabled, returns false (no exclusions apply). #[inline] pub fn is_address_excluded(&self, addr: &Address) -> bool { - match self { - Self::Disabled => false, - Self::Enabled { - addresses_exclusions, - .. - } => addresses_exclusions.iter().any(|a| a == addr), - } + self.addresses_exclusions.iter().any(|a| a == addr) + } + + /// Addresses excluded from denylist checks, deduplicated. + #[inline] + pub fn addresses_exclusions(&self) -> &[Address] { + &self.addresses_exclusions } } @@ -133,72 +122,21 @@ mod tests { use alloy_primitives::address; #[test] - fn default_is_disabled() { - let cfg = AddressesDenylistConfig::default(); - assert!(!cfg.is_enabled()); - } - - #[test] - fn try_new_enabled_with_both_succeeds() { + fn new_sets_address_and_slot() { let addr = address!("0x3600000000000000000000000000000000000001"); let slot = B256::from([1u8; 32]); - let cfg = - AddressesDenylistConfig::try_new(true, Some(addr), Some(slot), Vec::new()).unwrap(); - assert!(cfg.is_enabled()); - if let AddressesDenylistConfig::Enabled { - contract_address, - storage_slot, - .. - } = &cfg - { - assert_eq!(*contract_address, addr); - assert_eq!(*storage_slot, slot); - } else { - panic!("expected Enabled variant"); - } - } - - #[test] - fn try_new_enabled_without_address_fails() { - let err = - AddressesDenylistConfig::try_new(true, None, Some(B256::ZERO), Vec::new()).unwrap_err(); - assert!(matches!( - err, - AddressesDenylistConfigError::MissingContractAddress - )); - } - - #[test] - fn try_new_enabled_without_slot_fails() { - let err = AddressesDenylistConfig::try_new(true, Some(Address::ZERO), None, Vec::new()) - .unwrap_err(); - assert!(matches!( - err, - AddressesDenylistConfigError::MissingStorageSlot - )); - } - - #[test] - fn try_new_disabled_accepts_none() { - let cfg = AddressesDenylistConfig::try_new(false, None, None, Vec::new()).unwrap(); - assert!(!cfg.is_enabled()); + let cfg = AddressesDenylistConfig::new(addr, slot, Vec::new()); + assert_eq!(cfg.contract_address(), addr); + assert_eq!(cfg.storage_slot(), slot); + assert!(cfg.addresses_exclusions().is_empty()); } #[test] fn exclusions_deduplicated() { let addr = address!("0x3600000000000000000000000000000000000001"); let slot = B256::from([1u8; 32]); - let addrs = vec![addr, addr]; - let cfg = AddressesDenylistConfig::try_new(true, Some(addr), Some(slot), addrs).unwrap(); - if let AddressesDenylistConfig::Enabled { - addresses_exclusions, - .. - } = &cfg - { - assert_eq!(addresses_exclusions.len(), 1); - } else { - panic!("expected Enabled variant"); - } + let cfg = AddressesDenylistConfig::new(addr, slot, vec![addr, addr]); + assert_eq!(cfg.addresses_exclusions(), &[addr]); } #[test] @@ -206,8 +144,7 @@ mod tests { let addr1 = address!("0x3600000000000000000000000000000000000001"); let addr2 = address!("0x3600000000000000000000000000000000000002"); let slot = B256::from([1u8; 32]); - let cfg = - AddressesDenylistConfig::try_new(true, Some(addr1), Some(slot), vec![addr1]).unwrap(); + let cfg = AddressesDenylistConfig::new(addr1, slot, vec![addr1]); assert!(cfg.is_address_excluded(&addr1)); assert!(!cfg.is_address_excluded(&addr2)); } diff --git a/crates/execution-config/src/chainspec.rs b/crates/execution-config/src/chainspec.rs index ba1c6d97..b0a9ef88 100644 --- a/crates/execution-config/src/chainspec.rs +++ b/crates/execution-config/src/chainspec.rs @@ -19,8 +19,8 @@ use alloy_eips::eip7840::BlobParams; use alloy_evm::eth::spec::EthExecutorSpec; use alloy_genesis::Genesis; #[cfg(any(feature = "test-utils", test))] -use alloy_primitives::{address, b256}; -use alloy_primitives::{Address, U256}; +use alloy_primitives::b256; +use alloy_primitives::{address, Address, U256}; use eyre::Result; use once_cell::sync::Lazy as LazyLock; use reth_chainspec::{ @@ -39,6 +39,7 @@ use crate::hardforks::ArcHardfork; #[cfg(any(feature = "test-utils", test))] use crate::native_coin_control::compute_is_blocklisted_storage_slot; use crate::{ + addresses_denylist::DENYLIST_ADDRESS_LOCALDEV, gas_fee::decode_base_fee_from_bytes, hardforks::{ ArcGenesisInfo, ArcHardforkFlags, ARC_DEVNET_HARDFORKS, ARC_LOCALDEV_HARDFORKS, @@ -267,6 +268,20 @@ impl BaseFeeConfigProvider for &T { } } +/// Denylist proxy address on devnet. Deployed post-genesis, so it is absent from +/// `assets/devnet/genesis.json`. Mirrors `denylistAddressByNetwork.devnet` in +/// `scripts/genesis/addresses.ts`. +const DENYLIST_ADDRESS_DEVNET: Address = address!("0x36061d38f2d939249A947f1254097e0FFC9e2993"); + +/// Denylist proxy address on testnet. Deployed post-genesis, so it is absent from +/// `assets/testnet/genesis.json`. Mirrors `denylistAddressByNetwork.testnet` in +/// `scripts/genesis/addresses.ts`. +const DENYLIST_ADDRESS_TESTNET: Address = address!("0x360b451bb0490637F52fa1794961455615777757"); + +/// Denylist proxy address on mainnet — the next system-contract slot, deployed in genesis. +/// Mirrors `denylistAddressByNetwork.mainnet` in `scripts/genesis/addresses.ts`. +const DENYLIST_ADDRESS_MAINNET: Address = address!("0x3600000000000000000000000000000000000004"); + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ArcChainSpec { pub inner: ChainSpec, @@ -277,6 +292,26 @@ impl ArcChainSpec { Self { inner } } + /// Denylist contract address for this chain, or `None` if the chain has no Arc denylist. + /// + /// Hardcoded per network rather than operator-configurable: the denylist is a protocol + /// requirement, so disabling it or repointing it at a different list must require a source + /// change and a rebuild, not a CLI flag. Localdev's address is a mined CREATE2 address owned + /// by the genesis builder (see [`DENYLIST_ADDRESS_LOCALDEV`]); the deployed networks use + /// fixed addresses recorded above. + /// + /// Unrecognised chain IDs return `None`. There is no denylist-free node: the caller must + /// reject such a chain spec at startup rather than run without denylist checks. + pub fn denylist_address(&self) -> Option
{ + match self.chain().id() { + LOCALDEV_CHAIN_ID => Some(DENYLIST_ADDRESS_LOCALDEV), + DEVNET_CHAIN_ID => Some(DENYLIST_ADDRESS_DEVNET), + TESTNET_CHAIN_ID => Some(DENYLIST_ADDRESS_TESTNET), + MAINNET_CHAIN_ID => Some(DENYLIST_ADDRESS_MAINNET), + _ => None, + } + } + /// Get the hardfork flags for a given (block height, timestamp). /// /// Returns feature flags indicating which Arc hardforks are active at the given @@ -397,6 +432,9 @@ pub fn localdev_with_hardforks(hardforks: &[(ArcHardfork, ForkCondition)]) -> Ar ArcHardfork::Zero7 => inner .hardforks .insert(ArcHardfork::Zero7.boxed(), condition), + ArcHardfork::Zero8 => inner + .hardforks + .insert(ArcHardfork::Zero8.boxed(), condition), }; } @@ -439,7 +477,7 @@ pub fn localdev_with_denylisted_addresses( denylisted_addresses: impl IntoIterator, ) -> Arc { use crate::addresses_denylist::{ - compute_denylist_storage_slot, DEFAULT_DENYLIST_ADDRESS, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + compute_denylist_storage_slot, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, }; const DENYLISTED_STATUS: B256 = @@ -451,7 +489,7 @@ pub fn localdev_with_denylisted_addresses( let denylist_account = genesis .alloc - .get_mut(&DEFAULT_DENYLIST_ADDRESS) + .get_mut(&DENYLIST_ADDRESS_LOCALDEV) .expect("LOCAL_DEV genesis missing Denylist account"); let storage = denylist_account .storage @@ -817,6 +855,89 @@ impl EthExecutorSpec for ArcChainSpec { } // Test Arc LocalDev chain spec parsing +#[cfg(test)] +mod denylist_address_tests { + use super::*; + + /// Every network Arc recognises, paired with the address the genesis builder assigns it in + /// `scripts/genesis/addresses.ts` (`denylistAddressByNetwork`). Hardcoded here on purpose: if + /// the builder table changes, this test must be updated deliberately rather than silently + /// tracking it. + const EXPECTED: [(u64, &str, &str); 4] = [ + ( + LOCALDEV_CHAIN_ID, + "localdev", + "0x36059b615370eB999e8eC0c9401835B407834221", + ), + ( + DEVNET_CHAIN_ID, + "devnet", + "0x36061d38f2d939249A947f1254097e0FFC9e2993", + ), + ( + TESTNET_CHAIN_ID, + "testnet", + "0x360b451bb0490637F52fa1794961455615777757", + ), + ( + MAINNET_CHAIN_ID, + "mainnet", + "0x3600000000000000000000000000000000000004", + ), + ]; + + /// Guards against the denylist address drifting from the genesis builder. A mismatch means + /// nodes would read denylist storage from an address that holds no Denylist contract, which + /// reads as "nobody is denylisted" rather than as an error. + #[test] + fn denylist_address_matches_genesis_builder_for_every_network() { + for (chain_id, name, expected) in EXPECTED { + let spec = bundled_chainspec_for_chain_id(chain_id) + .unwrap_or_else(|| panic!("no bundled chain spec for {name}")); + assert_eq!( + spec.denylist_address(), + Some(expected.parse::
().unwrap()), + "denylist address for {name} does not match scripts/genesis/addresses.ts" + ); + } + } + + /// Localdev and mainnet deploy the Denylist in genesis, so the resolved address must hold + /// code there. Devnet and testnet deploy it post-genesis (see `scripts/genesis/genesis.ts`), + /// so they are intentionally excluded. + #[test] + fn denylist_contract_present_in_genesis_where_expected() { + for (chain_id, name) in [ + (LOCALDEV_CHAIN_ID, "localdev"), + (MAINNET_CHAIN_ID, "mainnet"), + ] { + let spec = bundled_chainspec_for_chain_id(chain_id).unwrap(); + let address = spec.denylist_address().unwrap(); + let account = spec + .inner + .genesis() + .alloc + .get(&address) + .unwrap_or_else(|| panic!("{name} genesis has no account at {address}")); + assert!( + account.code.as_ref().is_some_and(|c| !c.is_empty()), + "{name} genesis account at {address} has no code" + ); + } + } + + /// A custom chain spec has no Arc denylist to point at. The node rejects it at startup + /// (see `build_addresses_denylist_config`) rather than running without denylist checks. + #[test] + fn unknown_chain_id_has_no_denylist() { + let mut genesis: Genesis = + serde_json::from_str(include_str!("../../../assets/localdev/genesis.json")).unwrap(); + genesis.config.chain_id = 999999; + let spec = ArcChainSpec::new(ChainSpec::from_genesis(genesis)); + assert_eq!(spec.denylist_address(), None); + } +} + #[cfg(test)] mod tests { use super::*; @@ -832,9 +953,30 @@ mod tests { ARC_ZERO5_HARDFORK_BLOCK_ACTIVATION_DEVNET, ARC_ZERO5_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET, ARC_ZERO6_HARDFORK_BLOCK_ACTIVATION_DEVNET, - ARC_ZERO6_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET, BASE_FORKS, + ARC_ZERO6_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET, + ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET, BASE_FORKS, }; + #[test] + fn chain_names_used_by_the_snapshot_tooling_are_still_supported() { + // `arc-snapshots` passes these to `arc-node-execution download --chain` + // when it restores the execution layer from a reth manifest, and it does + // that after deleting the datadir. Renaming one here would leave that + // restore failing on an operator's node with the old data already gone, so + // the names are restated and checked. They come from + // `Chain::arc_chain_arg` in `crates/snapshots/src/download.rs`. + for chain in ["arc-devnet", "arc-testnet", "arc-mainnet"] { + assert!( + ArcChainSpecParser::SUPPORTED_CHAINS.contains(&chain), + "{chain} is no longer a supported --chain value; \ + update Chain::arc_chain_arg in crates/snapshots to match" + ); + } + } + fn assert_arc_chainspec_evm_hardforks(spec: &ArcChainSpec) { // ---- Block-gated forks (chronological) ---- // No helper function for Frontier @@ -951,7 +1093,7 @@ mod tests { .expect("Failed to parse arc-localdev"); assert_eq!(spec.chain().id(), LOCALDEV_CHAIN_ID); assert_arc_chainspec_evm_hardforks(&spec); - assert_eq!(spec.forks_iter().count(), 23); + assert_eq!(spec.forks_iter().count(), 24); assert!(spec.is_osaka_active_at_timestamp(0)); // verify zero3 hardfork block @@ -980,12 +1122,18 @@ mod tests { spec.is_fork_active_at_timestamp(ArcHardfork::Zero7, 0), "Zero7 should be active at timestamp 0 in hardfork.rs, and load by chainspec" ); + // Zero8 activates by timestamp (Arc convention from Zero7 onward). + assert!( + spec.is_fork_active_at_timestamp(ArcHardfork::Zero8, 0), + "Zero8 should be active at timestamp 0 in hardfork.rs, and load by chainspec" + ); let flags = spec.get_hardfork_flags(0, 0); assert!(flags.is_active(ArcHardfork::Zero3)); assert!(flags.is_active(ArcHardfork::Zero4)); assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(flags.is_active(ArcHardfork::Zero7)); + assert!(flags.is_active(ArcHardfork::Zero8)); } #[test] @@ -994,7 +1142,7 @@ mod tests { assert_eq!(spec.chain().id(), LOCALDEV_CHAIN_ID); assert_arc_chainspec_evm_hardforks(&spec); assert!(spec.is_osaka_active_at_timestamp(0)); - assert_eq!(spec.forks_iter().count(), 23); + assert_eq!(spec.forks_iter().count(), 24); // verify zero3 hardfork block assert!(!spec.is_fork_active_at_timestamp(ArcHardfork::Zero3, 1762732800)); @@ -1011,12 +1159,16 @@ mod tests { // Zero7 activates by timestamp (Arc convention from Zero7 onward). assert!(spec.is_fork_active_at_timestamp(ArcHardfork::Zero7, 0)); assert!(!spec.is_fork_active_at_block(ArcHardfork::Zero7, 0)); + // Zero8 activates by timestamp (Arc convention from Zero7 onward). + assert!(spec.is_fork_active_at_timestamp(ArcHardfork::Zero8, 0)); + assert!(!spec.is_fork_active_at_block(ArcHardfork::Zero8, 0)); let flags = spec.get_hardfork_flags(0, 0); assert!(flags.is_active(ArcHardfork::Zero3)); assert!(flags.is_active(ArcHardfork::Zero4)); assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(flags.is_active(ArcHardfork::Zero7)); + assert!(flags.is_active(ArcHardfork::Zero8)); assert_eq!( spec.display_hardforks().to_string(), r#"Pre-merge hard forks (block based): @@ -1044,7 +1196,8 @@ Post-merge hard forks (timestamp based): - Cancun @0 blob: (target: 6, max: 9, fraction: 5007716) - Prague @0 blob: (target: 6, max: 9, fraction: 5007716) - Osaka @0 blob: (target: 6, max: 9, fraction: 5007716) -- Zero7 @0 blob: (target: 6, max: 9, fraction: 5007716)"# +- Zero7 @0 blob: (target: 6, max: 9, fraction: 5007716) +- Zero8 @0 blob: (target: 6, max: 9, fraction: 5007716)"# ); } @@ -1064,15 +1217,41 @@ Post-merge hard forks (timestamp based): assert_arc_chainspec_evm_hardforks(&spec); assert!(spec.is_osaka_active_at_timestamp(0)); - assert_eq!(spec.forks_iter().count(), 22); + assert_eq!(spec.forks_iter().count(), 24); - // Mainnet launches at Zero6: Zero3..Zero6 active at block 0; Zero7 is not scheduled on mainnet. + // Mainnet launches at Zero6: Zero3..Zero6 active at block 0; Zero7/Zero8 activate + // later by timestamp. let flags = spec.get_hardfork_flags(0, 0); assert!(flags.is_active(ArcHardfork::Zero3)); assert!(flags.is_active(ArcHardfork::Zero4)); assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(!flags.is_active(ArcHardfork::Zero7)); + assert!(!flags.is_active(ArcHardfork::Zero8)); + + let flags_before_zero7 = + spec.get_hardfork_flags(0, ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET - 1); + assert!(!flags_before_zero7.is_active(ArcHardfork::Zero7)); + + let flags_at_zero7 = + spec.get_hardfork_flags(0, ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET); + assert!(flags_at_zero7.is_active(ArcHardfork::Zero7)); + assert_eq!( + spec.fork(ArcHardfork::Zero7), + ForkCondition::Timestamp(ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET) + ); + + let flags_before_zero8 = + spec.get_hardfork_flags(0, ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET - 1); + assert!(!flags_before_zero8.is_active(ArcHardfork::Zero8)); + + let flags_at_zero8 = + spec.get_hardfork_flags(0, ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET); + assert!(flags_at_zero8.is_active(ArcHardfork::Zero8)); + assert_eq!( + spec.fork(ArcHardfork::Zero8), + ForkCondition::Timestamp(ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET) + ); assert_eq!( spec.display_hardforks().to_string(), @@ -1100,7 +1279,9 @@ Post-merge hard forks (timestamp based): - Shanghai @0 blob: (target: 6, max: 9, fraction: 5007716) - Cancun @0 blob: (target: 6, max: 9, fraction: 5007716) - Prague @0 blob: (target: 6, max: 9, fraction: 5007716) -- Osaka @0 blob: (target: 6, max: 9, fraction: 5007716)"# +- Osaka @0 blob: (target: 6, max: 9, fraction: 5007716) +- Zero7 @1789052400 blob: (target: 6, max: 9, fraction: 5007716) +- Zero8 @1789052400 blob: (target: 6, max: 9, fraction: 5007716)"# ); } @@ -1167,6 +1348,7 @@ Post-merge hard forks (timestamp based): (ArcHardfork::Zero5, true), (ArcHardfork::Zero6, true), (ArcHardfork::Zero7, false), // deferred — not active at launch + (ArcHardfork::Zero8, false), // deferred — not active at launch ]; let paths: [(&str, &ArcChainSpec); 3] = [ ("parser", &from_parser), @@ -1200,7 +1382,7 @@ Post-merge hard forks (timestamp based): "0x41c417868fee948f58602b01a84ce0ddb5ffe2184f7e9ab43b9c8d7e5eb47067", "the genesis hash of assets/devnet/genesis.json changed unexpectedly" ); - assert_eq!(spec.forks_iter().count(), 23); + assert_eq!(spec.forks_iter().count(), 24); assert_arc_chainspec_evm_hardforks(&spec); assert!(!spec.is_osaka_active_at_timestamp(0)); assert!(spec.is_osaka_active_at_timestamp(ARC_OSAKA_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET)); @@ -1248,6 +1430,19 @@ Post-merge hard forks (timestamp based): assert!(flags_at_zero6.is_active(ArcHardfork::Zero4)); assert!(flags_at_zero6.is_active(ArcHardfork::Zero5)); assert!(flags_at_zero6.is_active(ArcHardfork::Zero6)); + assert!(!flags_at_zero6.is_active(ArcHardfork::Zero8)); + + let flags_before_zero8 = spec.get_hardfork_flags( + ARC_ZERO6_HARDFORK_BLOCK_ACTIVATION_DEVNET, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET - 1, + ); + assert!(!flags_before_zero8.is_active(ArcHardfork::Zero8)); + + let flags_at_zero8 = spec.get_hardfork_flags( + ARC_ZERO6_HARDFORK_BLOCK_ACTIVATION_DEVNET, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET, + ); + assert!(flags_at_zero8.is_active(ArcHardfork::Zero8)); assert_eq!( spec.display_hardforks().to_string(), @@ -1276,7 +1471,8 @@ Post-merge hard forks (timestamp based): - Cancun @0 blob: (target: 6, max: 9, fraction: 5007716) - Prague @0 blob: (target: 6, max: 9, fraction: 5007716) - Osaka @1775483400 blob: (target: 6, max: 9, fraction: 5007716) -- Zero7 @1780495200 blob: (target: 6, max: 9, fraction: 5007716)"# +- Zero7 @1780495200 blob: (target: 6, max: 9, fraction: 5007716) +- Zero8 @1787756400 blob: (target: 6, max: 9, fraction: 5007716)"# ); assert_eq!( spec.fork(ArcHardfork::Zero3), @@ -1294,6 +1490,10 @@ Post-merge hard forks (timestamp based): spec.fork(ArcHardfork::Zero6), ForkCondition::Block(ARC_ZERO6_HARDFORK_BLOCK_ACTIVATION_DEVNET) ); + assert_eq!( + spec.fork(ArcHardfork::Zero8), + ForkCondition::Timestamp(ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET) + ); } #[test] @@ -1310,7 +1510,7 @@ Post-merge hard forks (timestamp based): assert_arc_chainspec_evm_hardforks(&spec); assert!(!spec.is_osaka_active_at_timestamp(0)); assert!(spec.is_osaka_active_at_timestamp(ARC_OSAKA_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET)); - assert_eq!(spec.forks_iter().count(), 23); + assert_eq!(spec.forks_iter().count(), 24); // Zero3 let flags_before_zero3 = @@ -1379,6 +1579,23 @@ Post-merge hard forks (timestamp based): ForkCondition::Timestamp(ARC_ZERO6_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET) ); + // Zero8 — activates by timestamp on testnet. + let flags_before_zero8 = spec.get_hardfork_flags( + post_zero4_block, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET - 1, + ); + assert!(!flags_before_zero8.is_active(ArcHardfork::Zero8)); + + let flags_at_zero8 = spec.get_hardfork_flags( + post_zero4_block, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET, + ); + assert!(flags_at_zero8.is_active(ArcHardfork::Zero8)); + assert_eq!( + spec.fork(ArcHardfork::Zero8), + ForkCondition::Timestamp(ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET) + ); + assert_eq!( spec.fork(EthereumHardfork::Osaka), ForkCondition::Timestamp(ARC_OSAKA_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET) @@ -1411,7 +1628,8 @@ Post-merge hard forks (timestamp based): - Osaka @1779890400 blob: (target: 6, max: 9, fraction: 5007716) - Zero5 @1779894517 blob: (target: 6, max: 9, fraction: 5007716) - Zero6 @1779894517 blob: (target: 6, max: 9, fraction: 5007716) -- Zero7 @1781791200 blob: (target: 6, max: 9, fraction: 5007716)"# +- Zero7 @1781791200 blob: (target: 6, max: 9, fraction: 5007716) +- Zero8 @1788447600 blob: (target: 6, max: 9, fraction: 5007716)"# ); } diff --git a/crates/execution-config/src/defaults.rs b/crates/execution-config/src/defaults.rs index bbed0d3a..e6eb8759 100644 --- a/crates/execution-config/src/defaults.rs +++ b/crates/execution-config/src/defaults.rs @@ -42,6 +42,9 @@ fn init_download_urls() { ], default_base_url: Cow::Borrowed(DEFAULT_DOWNLOAD_URL), default_chain_aware_base_url: None, + // Reth's built-in snapshot-API discovery is left disabled; arc-snapshots + // resolves snapshot locations itself. + snapshot_api_url: Cow::Borrowed(""), long_help: None, }; let _ = download_defaults.try_init(); @@ -128,4 +131,24 @@ mod tests { RPC_MAX_SUBSCRIPTIONS_PER_CONNECTION, ); } + + #[test] + fn init_defaults_registers_arc_snapshot_urls() { + ensure_initialized(); + + let global = DownloadDefaults::get_global(); + assert_eq!(global.default_base_url.as_ref(), DEFAULT_DOWNLOAD_URL); + assert!(global.default_chain_aware_base_url.is_none()); + assert!(global.snapshot_api_url.is_empty()); + assert!(global.long_help.is_none()); + assert_eq!(global.available_snapshots.len(), 2); + assert!(global + .available_snapshots + .iter() + .any(|s| s.contains("5042002") && s.contains("testnet"))); + assert!(global + .available_snapshots + .iter() + .any(|s| s.contains("5042001") && s.contains("devnet"))); + } } diff --git a/crates/execution-config/src/follow.rs b/crates/execution-config/src/follow.rs index 30acc859..37b430d1 100644 --- a/crates/execution-config/src/follow.rs +++ b/crates/execution-config/src/follow.rs @@ -24,7 +24,7 @@ use arc_shared::chain_ids::{LOCALDEV_CHAIN_ID, TESTNET_CHAIN_ID}; /// Returns the WebSocket URL for the given chain ID. pub fn ws_url_for_chain_id(chain_id: u64) -> Result { let url = match chain_id { - TESTNET_CHAIN_ID => "wss://rpc.quicknode.testnet.arc.network", + TESTNET_CHAIN_ID => "wss://rpc.testnet.arc.io", LOCALDEV_CHAIN_ID => "ws://localhost:8546", _ => return Err(eyre!("Unsupported chain for follow mode: {}", chain_id)), }; @@ -64,7 +64,7 @@ mod tests { #[test] fn test_ws_url_for_chain_id_testnet() { let url = ws_url_for_chain_id(TESTNET_CHAIN_ID).unwrap(); - assert_eq!(url, "wss://rpc.quicknode.testnet.arc.network"); + assert_eq!(url, "wss://rpc.testnet.arc.io"); } #[test] diff --git a/crates/execution-config/src/hardforks.rs b/crates/execution-config/src/hardforks.rs index 71fbd084..5b37e4cb 100644 --- a/crates/execution-config/src/hardforks.rs +++ b/crates/execution-config/src/hardforks.rs @@ -33,6 +33,7 @@ hardfork!( #[default] Zero6, // v0.6 hardfork Zero7, // v0.7 hardfork — batch (Multicall3From) and memo contracts + Zero8, // v0.8 hardfork — delegatecall rejections charge the uniform early-revert penalty } ); @@ -54,6 +55,8 @@ pub struct ArcGenesisInfo { /// Built-in network schedules are defined in `ARC_*_HARDFORKS` and may activate /// earlier Arc hardforks by timestamp, e.g. testnet Zero5/Zero6. pub zero_7_time: Option, + /// v0.8 hardfork timestamp for genesis-configured chains. + pub zero_8_time: Option, } impl ArcGenesisInfo { @@ -78,6 +81,9 @@ impl ArcGenesisInfo { if let Some(time) = self.zero_7_time { hardforks.push((ArcHardfork::Zero7, ForkCondition::Timestamp(time))); } + if let Some(time) = self.zero_8_time { + hardforks.push((ArcHardfork::Zero8, ForkCondition::Timestamp(time))); + } hardforks } } @@ -101,6 +107,7 @@ pub struct ArcHardforkFlags { zero5: bool, zero6: bool, zero7: bool, + zero8: bool, } impl ArcHardforkFlags { @@ -111,6 +118,7 @@ impl ArcHardforkFlags { ArcHardfork::Zero5, ArcHardfork::Zero6, ArcHardfork::Zero7, + ArcHardfork::Zero8, ]; /// Check if a specific hardfork is active. @@ -121,6 +129,7 @@ impl ArcHardforkFlags { ArcHardfork::Zero5 => self.zero5, ArcHardfork::Zero6 => self.zero6, ArcHardfork::Zero7 => self.zero7, + ArcHardfork::Zero8 => self.zero8, } } @@ -132,6 +141,7 @@ impl ArcHardforkFlags { ArcHardfork::Zero5 => self.zero5 = value, ArcHardfork::Zero6 => self.zero6 = value, ArcHardfork::Zero7 => self.zero7 = value, + ArcHardfork::Zero8 => self.zero8 = value, } } @@ -278,6 +288,7 @@ pub static ARC_LOCALDEV_HARDFORKS: LazyLock = LazyLock::new(|| { // mixed-version peers (block-based forks declared after a timestamp fork break // ForkFilter's BTreeMap ordering). forks.insert(ArcHardfork::Zero7.boxed(), ForkCondition::Timestamp(0)); + forks.insert(ArcHardfork::Zero8.boxed(), ForkCondition::Timestamp(0)); forks }); @@ -290,6 +301,14 @@ pub static ARC_MAINNET_HARDFORKS: LazyLock = LazyLock::new(|| { forks.insert(EthereumHardfork::Osaka.boxed(), ForkCondition::Timestamp(0)); forks.insert(ArcHardfork::Zero5.boxed(), ForkCondition::Block(0)); forks.insert(ArcHardfork::Zero6.boxed(), ForkCondition::Block(0)); + forks.insert( + ArcHardfork::Zero7.boxed(), + ForkCondition::Timestamp(ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET), + ); + forks.insert( + ArcHardfork::Zero8.boxed(), + ForkCondition::Timestamp(ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET), + ); forks }); @@ -320,6 +339,10 @@ pub static ARC_DEVNET_HARDFORKS: LazyLock = LazyLock::new(|| { ArcHardfork::Zero7.boxed(), ForkCondition::Timestamp(ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET), ); + forks.insert( + ArcHardfork::Zero8.boxed(), + ForkCondition::Timestamp(ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET), + ); forks }); @@ -353,6 +376,10 @@ pub static ARC_TESTNET_HARDFORKS: LazyLock = LazyLock::new(|| { ArcHardfork::Zero7.boxed(), ForkCondition::Timestamp(ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET), ); + forks.insert( + ArcHardfork::Zero8.boxed(), + ForkCondition::Timestamp(ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET), + ); forks }); @@ -366,6 +393,7 @@ pub const ARC_ZERO4_HARDFORK_BLOCK_ACTIVATION_DEVNET: u64 = 19491165; pub const ARC_ZERO4_HARDFORK_BLOCK_ACTIVATION_TESTNET: u64 = 26148086; /// Zero5 pub const ARC_ZERO5_HARDFORK_BLOCK_ACTIVATION_DEVNET: u64 = 32371192; +// Zero5/Zero6 and Osaka are already activated on testnet/mainnet. pub const ARC_ZERO5_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET: u64 = 1779894517; /// Zero6 pub const ARC_ZERO6_HARDFORK_BLOCK_ACTIVATION_DEVNET: u64 = 40033853; @@ -376,6 +404,11 @@ pub const ARC_OSAKA_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET: u64 = 1779890400; /// Zero7 pub const ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET: u64 = 1780495200; pub const ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET: u64 = 1781791200; +pub const ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET: u64 = 1789052400; +/// Zero8 +pub const ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET: u64 = 1787756400; +pub const ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET: u64 = 1788447600; +pub const ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_MAINNET: u64 = 1789052400; #[cfg(test)] mod tests { @@ -389,6 +422,7 @@ mod tests { assert_eq!(ArcHardfork::Zero5.name(), "Zero5"); assert_eq!(ArcHardfork::Zero6.name(), "Zero6"); assert_eq!(ArcHardfork::Zero7.name(), "Zero7"); + assert_eq!(ArcHardfork::Zero8.name(), "Zero8"); } #[test] @@ -400,6 +434,7 @@ mod tests { assert!(!flags.is_active(ArcHardfork::Zero5)); assert!(!flags.is_active(ArcHardfork::Zero6)); assert!(!flags.is_active(ArcHardfork::Zero7)); + assert!(!flags.is_active(ArcHardfork::Zero8)); // Test from chain hardforks - localdev has all Arc hardforks active at genesis let flags = ArcHardforkFlags::from_chain_hardforks(&ARC_LOCALDEV_HARDFORKS, 0, 0); @@ -408,6 +443,7 @@ mod tests { assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(flags.is_active(ArcHardfork::Zero7)); + assert!(flags.is_active(ArcHardfork::Zero8)); // Test from chain hardforks - devnet has Zero3 and Zero4 active after their activation blocks let flags = ArcHardforkFlags::from_chain_hardforks( @@ -420,6 +456,7 @@ mod tests { assert!(!flags.is_active(ArcHardfork::Zero5)); assert!(!flags.is_active(ArcHardfork::Zero6)); assert!(!flags.is_active(ArcHardfork::Zero7)); + assert!(!flags.is_active(ArcHardfork::Zero8)); // Test from chain hardforks - devnet before Zero4 activation let flags = ArcHardforkFlags::from_chain_hardforks( @@ -456,9 +493,9 @@ mod tests { assert!(!flags.is_active(ArcHardfork::Zero3)); assert!(!flags.is_active(ArcHardfork::Zero4)); - // Test all_combinations() helper - should yield 32 combinations (2^5) + // Test all_combinations() helper - should yield 64 combinations (2^6) let combinations: Vec<_> = ArcHardforkFlags::all_combinations().collect(); - assert_eq!(combinations.len(), 32); + assert_eq!(combinations.len(), 64); // Verify some key combinations are present assert!(combinations.contains(&ArcHardforkFlags::with(&[]))); @@ -474,12 +511,13 @@ mod tests { ArcHardfork::Zero5, ArcHardfork::Zero6, ArcHardfork::Zero7, + ArcHardfork::Zero8, ]))); } #[test] fn test_parse_arc_hardfork_from_genesis() { - let s = r#"{ "config": { "zero3Block": 123123, "zero4Block": 223881, "zero5Block": 323496, "zero6Block": 423000, "zero7Time": 1800000000 } }"#; + let s = r#"{ "config": { "zero3Block": 123123, "zero4Block": 223881, "zero5Block": 323496, "zero6Block": 423000, "zero7Time": 1800000000, "zero8Time": 1900000000 } }"#; let genesis = serde_json::from_str::(s).expect("Failed to parse genesis"); let info = ArcGenesisInfo::extract_from(&genesis.config.extra_fields) @@ -489,6 +527,7 @@ mod tests { assert_eq!(info.zero_5_block, Some(323496)); assert_eq!(info.zero_6_block, Some(423000)); assert_eq!(info.zero_7_time, Some(1800000000)); + assert_eq!(info.zero_8_time, Some(1900000000)); } // Verify ethereum hardforks are supported for all networks. @@ -531,7 +570,7 @@ mod tests { fn test_arc_localdev_forks() { let forks = ARC_LOCALDEV_HARDFORKS.clone(); assert_base_hardforks(&forks); - assert_eq!(forks.len(), 23); + assert_eq!(forks.len(), 24); // verify hardfork zero3 block assert!(!forks.is_fork_active_at_timestamp(ArcHardfork::Zero3, 0)); @@ -552,13 +591,17 @@ mod tests { // Zero7 activates by timestamp (Arc convention from Zero7 onward). assert!(forks.is_fork_active_at_timestamp(ArcHardfork::Zero7, 0)); assert!(!forks.is_fork_active_at_block(ArcHardfork::Zero7, 0)); + + // Zero8 activates by timestamp (Arc convention from Zero7 onward). + assert!(forks.is_fork_active_at_timestamp(ArcHardfork::Zero8, 0)); + assert!(!forks.is_fork_active_at_block(ArcHardfork::Zero8, 0)); } #[test] fn test_arc_devnet_forks() { let forks = ARC_DEVNET_HARDFORKS.clone(); assert_base_hardforks(&forks); - assert_eq!(forks.len(), 23); + assert_eq!(forks.len(), 24); // verify hardfork zero3 block assert_eq!( @@ -655,13 +698,28 @@ mod tests { ArcHardfork::Zero7, ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET )); + // verify hardfork zero8 timestamp + assert_eq!( + forks.get(ArcHardfork::Zero8), + Some(ForkCondition::Timestamp( + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET + )) + ); + assert!(!forks.is_fork_active_at_timestamp( + ArcHardfork::Zero8, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET - 1 + )); + assert!(forks.is_fork_active_at_timestamp( + ArcHardfork::Zero8, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_DEVNET + )); } #[test] fn test_arc_testnet_forks() { let forks = ARC_TESTNET_HARDFORKS.clone(); assert_base_hardforks(&forks); - assert_eq!(forks.len(), 23); + assert_eq!(forks.len(), 24); // verify hardfork zero3 block assert_eq!( @@ -758,6 +816,22 @@ mod tests { ArcHardfork::Zero7, ARC_ZERO7_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET )); + + // verify hardfork zero8 timestamp + assert_eq!( + forks.get(ArcHardfork::Zero8), + Some(ForkCondition::Timestamp( + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET + )) + ); + assert!(!forks.is_fork_active_at_timestamp( + ArcHardfork::Zero8, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET - 1 + )); + assert!(forks.is_fork_active_at_timestamp( + ArcHardfork::Zero8, + ARC_ZERO8_HARDFORK_TIMESTAMP_ACTIVATION_TESTNET + )); } /// Per-network policy: from a given cutoff hardfork (inclusive) onward, all diff --git a/crates/execution-e2e/Cargo.toml b/crates/execution-e2e/Cargo.toml index 18f7c03d..44d3d8f9 100644 --- a/crates/execution-e2e/Cargo.toml +++ b/crates/execution-e2e/Cargo.toml @@ -20,12 +20,13 @@ alloy-primitives.workspace = true alloy-rpc-types-engine.workspace = true alloy-rpc-types-eth.workspace = true alloy-rpc-types-trace.workspace = true +alloy-serde.workspace = true +alloy-signer-local.workspace = true alloy-sol-types.workspace = true arc-evm-node = { path = "../evm-node" } # Arc crates arc-execution-config = { path = "../execution-config", features = ["test-utils"] } arc-execution-txpool = { path = "../execution-txpool" } -arc-precompiles = { path = "../precompiles" } # Error handling eyre.workspace = true @@ -56,7 +57,7 @@ tokio = { workspace = true, features = ["full"] } tracing.workspace = true [dev-dependencies] -arc-precompiles = { path = "../precompiles", features = ["test-utils"] } +arc-precompiles = { path = "../precompiles", features = ["integration", "test-utils"] } reth-tracing.workspace = true revm-bytecode.workspace = true rstest.workspace = true diff --git a/crates/execution-e2e/src/action.rs b/crates/execution-e2e/src/action.rs deleted file mode 100644 index 00a41537..00000000 --- a/crates/execution-e2e/src/action.rs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Action trait and utilities for Arc e2e tests. - -use crate::ArcEnvironment; -use futures_util::future::BoxFuture; - -/// An action that can be executed on the Arc test environment. -/// -/// Actions are the building blocks of test scenarios. -pub trait Action: Send + 'static { - /// Executes the action on the given environment. - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>>; -} - -/// Type-erased wrapper for actions, allowing storage in heterogeneous collections. -pub struct ActionBox(Box); - -impl ActionBox { - /// Creates a new boxed action. - pub fn new(action: A) -> Self { - Self(Box::new(action)) - } - - /// Executes the wrapped action. - pub async fn execute(&mut self, env: &mut ArcEnvironment) -> eyre::Result<()> { - self.0.execute(env).await - } -} diff --git a/crates/execution-e2e/src/actions/assert_named.rs b/crates/execution-e2e/src/actions/assert_named.rs deleted file mode 100644 index 3ba3f47b..00000000 --- a/crates/execution-e2e/src/actions/assert_named.rs +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Environment-aware assertion actions that resolve named addresses at execution time. -//! -//! These complement the static `AssertTxLogs` and `AssertBalance` actions for cases -//! where the target address is only known after a prior action (e.g. `StoreDeployedAddress`) -//! has run. - -use crate::{action::Action, ArcEnvironment}; -use alloy_primitives::{Address, U256}; -use alloy_rpc_types_eth::BlockNumberOrTag; -use futures_util::future::BoxFuture; -use reth_provider::ReceiptProvider; -use reth_rpc_api::EthApiClient; -use tracing::info; - -use super::assert_tx_logs::{validate_event_log, TRANSFER_EVENT_SIGNATURE}; - -/// An address reference that can be either a concrete address or a named address -/// resolved from the environment at execution time. -#[derive(Clone)] -pub enum AddressRef { - /// A concrete address known at builder time. - Literal(Address), - /// A named address resolved from the environment at execution time. - Named(String), -} - -impl AddressRef { - fn resolve(&self, env: &ArcEnvironment) -> eyre::Result
{ - match self { - Self::Literal(addr) => Ok(*addr), - Self::Named(name) => env - .get_address(name) - .copied() - .ok_or_else(|| eyre::eyre!("Named address '{}' not found in environment", name)), - } - } -} - -impl From
for AddressRef { - fn from(addr: Address) -> Self { - Self::Literal(addr) - } -} - -/// Asserts an EIP-7708 Transfer event at a specific log index in a named transaction's receipt. -/// -/// Resolves `from` and `to` from the environment at execution time, supporting -/// both literal addresses and named deployed-contract addresses. -pub struct AssertTransferEvent { - tx_name: String, - log_index: usize, - from: AddressRef, - to: AddressRef, - value: U256, -} - -impl AssertTransferEvent { - /// Creates a new transfer event assertion. - /// - /// `from` and `to` accept either `Address` or `AddressRef::Named("name")`. - pub fn new( - tx_name: impl Into, - log_index: usize, - from: impl Into, - to: impl Into, - value: U256, - ) -> Self { - Self { - tx_name: tx_name.into(), - log_index, - from: from.into(), - to: to.into(), - value, - } - } - - /// Helper: named address reference for use with `from` / `to` parameters. - pub fn named(name: impl Into) -> AddressRef { - AddressRef::Named(name.into()) - } -} - -impl Action for AssertTransferEvent { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let from = self.from.resolve(env)?; - let to = self.to.resolve(env)?; - - let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { - eyre::eyre!("Transaction '{}' not found in environment", self.tx_name) - })?; - - let receipt = env - .node() - .inner - .provider() - .receipt_by_hash(tx_hash)? - .ok_or_else(|| { - eyre::eyre!("Receipt not found for tx '{}' ({})", self.tx_name, tx_hash) - })?; - - let log = receipt.logs.get(self.log_index).ok_or_else(|| { - eyre::eyre!( - "Tx '{}': no log at index {} (total: {})", - self.tx_name, - self.log_index, - receipt.logs.len() - ) - })?; - - validate_event_log( - &self.tx_name, - self.log_index, - log, - TRANSFER_EVENT_SIGNATURE, - from, - to, - self.value, - )?; - - info!( - tx = %self.tx_name, - index = self.log_index, - from = %from, - to = %to, - value = %self.value, - "Transfer event assertion passed" - ); - Ok(()) - }) - } -} - -/// Asserts account balance for a named address from the environment. -pub struct AssertNamedBalance { - address_name: String, - expected: U256, -} - -impl AssertNamedBalance { - /// Assert the balance of a named address equals the expected value. - pub fn of(address_name: impl Into) -> AssertNamedBalanceBuilder { - AssertNamedBalanceBuilder { - address_name: address_name.into(), - } - } -} - -/// Builder for `AssertNamedBalance`. -pub struct AssertNamedBalanceBuilder { - address_name: String, -} - -impl AssertNamedBalanceBuilder { - /// Assert exact balance match. - pub fn equals(self, expected: U256) -> AssertNamedBalance { - AssertNamedBalance { - address_name: self.address_name, - expected, - } - } -} - -impl Action for AssertNamedBalance { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let address = *env.get_address(&self.address_name).ok_or_else(|| { - eyre::eyre!( - "Named address '{}' not found in environment", - self.address_name - ) - })?; - - let block_number = env.block_number(); - - info!( - name = %self.address_name, - address = %address, - expected = %self.expected, - block_number, - "Asserting named account balance" - ); - - let client = env - .node() - .rpc_client() - .ok_or_else(|| eyre::eyre!("RPC client not available"))?; - - let balance: U256 = >::balance( - &client, - address, - Some(BlockNumberOrTag::Number(block_number).into()), - ) - .await - .map_err(|e| { - eyre::eyre!( - "eth_getBalance failed for '{}' ({}) at block {}: {}", - self.address_name, - address, - block_number, - e - ) - })?; - - if balance != self.expected { - return Err(eyre::eyre!( - "Balance mismatch for '{}' ({}): expected {}, got {} (block {})", - self.address_name, - address, - self.expected, - balance, - block_number - )); - } - - info!( - name = %self.address_name, - address = %address, - balance = %balance, - "Named balance assertion passed" - ); - Ok(()) - }) - } -} diff --git a/crates/execution-e2e/src/actions/assert_tx_logs.rs b/crates/execution-e2e/src/actions/assert_tx_logs.rs deleted file mode 100644 index f1f3eb55..00000000 --- a/crates/execution-e2e/src/actions/assert_tx_logs.rs +++ /dev/null @@ -1,337 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Receipt log assertion action for EIP-7708 e2e tests. - -use crate::{action::Action, ArcEnvironment}; -use alloy_primitives::{Address, Bytes, B256, U256}; -use alloy_sol_types::{sol, SolEvent}; -use futures_util::future::BoxFuture; -use reth_provider::ReceiptProvider; -use tracing::info; - -/// Expected log entry at a specific index. -enum ExpectedLog { - /// Exact match on address, topics, and data. - Exact { - address: Address, - topics: Vec, - data: Bytes, - }, - /// ERC-20 Transfer(address,address,uint256) decode helper. - TransferEvent { - from: Address, - to: Address, - value: U256, - }, - /// NativeCoinTransferred(address,address,uint256) decode helper (pre-Zero5). - NativeCoinTransferredEvent { - from: Address, - to: Address, - amount: U256, - }, - /// Verify only the emitter address at a given index. - EmitterOnly { address: Address }, -} - -sol! { - event Transfer(address indexed from, address indexed to, uint256 value); - event NativeCoinTransferred(address indexed from, address indexed to, uint256 amount); -} - -/// keccak256("Transfer(address,address,uint256)") — derived from sol! macro. -pub const TRANSFER_EVENT_SIGNATURE: B256 = Transfer::SIGNATURE_HASH; - -/// keccak256("NativeCoinTransferred(address,address,uint256)") — derived from sol! macro. -pub const NATIVE_COIN_TRANSFERRED_SIGNATURE: B256 = NativeCoinTransferred::SIGNATURE_HASH; - -/// Validates a log entry against expected 3-topic event fields (signature, from, to, data). -/// -/// Shared by `AssertTxLogs` and `AssertTransferEvent` to avoid duplicated validation logic. -pub(crate) fn validate_event_log( - tx_name: &str, - index: usize, - log: &reth_ethereum::primitives::Log, - signature: B256, - from: Address, - to: Address, - value: U256, -) -> eyre::Result<()> { - let topics = log.topics(); - if topics.len() != 3 { - return Err(eyre::eyre!( - "Tx '{}' log[{}]: expected 3 topics, got {}", - tx_name, - index, - topics.len() - )); - } - if topics[0] != signature { - return Err(eyre::eyre!( - "Tx '{}' log[{}]: topic[0] signature mismatch: expected {}, got {}", - tx_name, - index, - signature, - topics[0] - )); - } - let expected_from = B256::left_padding_from(from.as_slice()); - if topics[1] != expected_from { - return Err(eyre::eyre!( - "Tx '{}' log[{}]: topic[1] (from) mismatch: expected {}, got {}", - tx_name, - index, - expected_from, - topics[1] - )); - } - let expected_to = B256::left_padding_from(to.as_slice()); - if topics[2] != expected_to { - return Err(eyre::eyre!( - "Tx '{}' log[{}]: topic[2] (to) mismatch: expected {}, got {}", - tx_name, - index, - expected_to, - topics[2] - )); - } - let expected_data = value.to_be_bytes::<32>(); - if log.data.data.as_ref() != expected_data.as_slice() { - return Err(eyre::eyre!( - "Tx '{}' log[{}]: data mismatch: expected {}, got {}", - tx_name, - index, - value, - log.data.data - )); - } - Ok(()) -} - -/// Asserts on receipt logs for a named transaction. -/// -/// Retrieves receipt via the provider, then validates log count -/// and individual log entries against expectations. -pub struct AssertTxLogs { - tx_name: String, - expected_log_count: Option, - expected_logs: Vec<(usize, ExpectedLog)>, -} - -impl AssertTxLogs { - /// Creates a new log assertion for the named transaction. - pub fn new(tx_name: impl Into) -> Self { - Self { - tx_name: tx_name.into(), - expected_log_count: None, - expected_logs: Vec::new(), - } - } - - /// Assert exact total number of logs. - pub fn expect_log_count(mut self, count: usize) -> Self { - self.expected_log_count = Some(count); - self - } - - /// Shorthand for `expect_log_count(0)`. - pub fn expect_no_logs(self) -> Self { - self.expect_log_count(0) - } - - /// Exact match on a single log entry. - pub fn expect_log_at( - mut self, - index: usize, - address: Address, - topics: Vec, - data: Bytes, - ) -> Self { - self.expected_logs.push(( - index, - ExpectedLog::Exact { - address, - topics, - data, - }, - )); - self - } - - /// Decode helper for ERC-20 Transfer(address,address,uint256). - pub fn expect_transfer_event( - mut self, - index: usize, - from: Address, - to: Address, - value: U256, - ) -> Self { - self.expected_logs - .push((index, ExpectedLog::TransferEvent { from, to, value })); - self - } - - /// Decode helper for pre-Zero5 NativeCoinTransferred(address,address,uint256). - pub fn expect_native_coin_transferred_event( - mut self, - index: usize, - from: Address, - to: Address, - amount: U256, - ) -> Self { - self.expected_logs.push(( - index, - ExpectedLog::NativeCoinTransferredEvent { from, to, amount }, - )); - self - } - - /// Verify only the emitter address at a given index. - pub fn expect_emitter_at(mut self, index: usize, address: Address) -> Self { - self.expected_logs - .push((index, ExpectedLog::EmitterOnly { address })); - self - } -} - -impl Action for AssertTxLogs { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { - eyre::eyre!("Transaction '{}' not found in environment", self.tx_name) - })?; - - info!( - name = %self.tx_name, - tx_hash = %tx_hash, - "Asserting transaction receipt logs" - ); - - let receipt = env - .node() - .inner - .provider() - .receipt_by_hash(tx_hash)? - .ok_or_else(|| { - eyre::eyre!("Receipt not found for tx '{}' ({})", self.tx_name, tx_hash) - })?; - - let logs = &receipt.logs; - - // Assert log count - if let Some(expected_count) = self.expected_log_count { - if logs.len() != expected_count { - return Err(eyre::eyre!( - "Tx '{}': expected {} logs, got {}. Logs: {:?}", - self.tx_name, - expected_count, - logs.len(), - logs - )); - } - } - - // Assert individual logs - for (index, expected) in &self.expected_logs { - let log = logs.get(*index).ok_or_else(|| { - eyre::eyre!( - "Tx '{}': no log at index {} (total: {})", - self.tx_name, - index, - logs.len() - ) - })?; - - match expected { - ExpectedLog::Exact { - address, - topics, - data, - } => { - if log.address != *address { - return Err(eyre::eyre!( - "Tx '{}' log[{}]: emitter mismatch: expected {}, got {}", - self.tx_name, - index, - address, - log.address - )); - } - let log_topics: Vec = log.topics().to_vec(); - if log_topics != *topics { - return Err(eyre::eyre!( - "Tx '{}' log[{}]: topics mismatch: expected {:?}, got {:?}", - self.tx_name, - index, - topics, - log_topics - )); - } - if log.data.data != *data { - return Err(eyre::eyre!( - "Tx '{}' log[{}]: data mismatch: expected {}, got {}", - self.tx_name, - index, - data, - log.data.data - )); - } - } - ExpectedLog::TransferEvent { from, to, value } => { - validate_event_log( - &self.tx_name, - *index, - log, - TRANSFER_EVENT_SIGNATURE, - *from, - *to, - *value, - )?; - } - ExpectedLog::NativeCoinTransferredEvent { from, to, amount } => { - validate_event_log( - &self.tx_name, - *index, - log, - NATIVE_COIN_TRANSFERRED_SIGNATURE, - *from, - *to, - *amount, - )?; - } - ExpectedLog::EmitterOnly { address } => { - if log.address != *address { - return Err(eyre::eyre!( - "Tx '{}' log[{}]: emitter mismatch: expected {}, got {}", - self.tx_name, - index, - address, - log.address - )); - } - } - } - } - - info!( - name = %self.tx_name, - log_count = logs.len(), - "Receipt log assertions passed" - ); - Ok(()) - }) - } -} diff --git a/crates/execution-e2e/src/actions/assert_tx_trace.rs b/crates/execution-e2e/src/actions/assert_tx_trace.rs deleted file mode 100644 index c14365bf..00000000 --- a/crates/execution-e2e/src/actions/assert_tx_trace.rs +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Debug trace assertion action for EIP-7708 e2e tests. - -use crate::{action::Action, ArcEnvironment}; -use alloy_rpc_types_trace::geth::{ - GethDebugBuiltInTracerType, GethDebugTracerConfig, GethDebugTracerType, - GethDebugTracingOptions, GethDefaultTracingOptions, GethTrace, -}; -use futures_util::future::BoxFuture; -use reth_rpc_api::DebugApiClient; -use tracing::info; - -/// Calls `debug_traceTransaction` for a named tx and asserts the call succeeds. -/// -/// At minimum, every test instantiates this to verify the tracer does not panic -/// on EIP-7708 transactions. Content assertions (log count, topics, data) are -/// provided as builder methods but should be commented out until the tracing bug is fixed. -pub struct AssertTxTrace { - tx_name: String, -} - -impl AssertTxTrace { - /// Creates a new trace assertion for the named transaction. - /// - /// The trace call uses `callTracer` with `{ withLog: true, onlyTopCall: false }`. - pub fn new(tx_name: impl Into) -> Self { - Self { - tx_name: tx_name.into(), - } - } -} - -impl Action for AssertTxTrace { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { - eyre::eyre!("Transaction '{}' not found in environment", self.tx_name) - })?; - - info!( - name = %self.tx_name, - tx_hash = %tx_hash, - "Calling debug_traceTransaction with callTracer" - ); - - let client = env - .node() - .rpc_client() - .ok_or_else(|| eyre::eyre!("RPC client not available"))?; - - let opts = GethDebugTracingOptions { - tracer: Some(GethDebugTracerType::BuiltInTracer( - GethDebugBuiltInTracerType::CallTracer, - )), - tracer_config: GethDebugTracerConfig( - serde_json::json!({ "withLog": true, "onlyTopCall": false }), - ), - ..Default::default() - }; - - let trace = >::debug_trace_transaction(&client, tx_hash, Some(opts)) - .await - .map_err(|e| { - eyre::eyre!( - "debug_traceTransaction failed for tx '{}' ({}): {}", - self.tx_name, - tx_hash, - e - ) - })?; - - info!( - name = %self.tx_name, - tx_hash = %tx_hash, - trace_variant = ?std::mem::discriminant(&trace), - "debug_traceTransaction succeeded" - ); - - Ok(()) - }) - } -} - -/// Calls `debug_traceTransaction` with the default struct logger and asserts the -/// gas cost of the last occurrence of an opcode. -pub struct AssertLastOpcodeGasCost { - tx_name: String, - opcode: String, - expected_gas_cost: u64, -} - -impl AssertLastOpcodeGasCost { - /// Creates a new opcode gas-cost assertion for the named transaction. - pub fn new(tx_name: impl Into, opcode: impl Into, gas_cost: u64) -> Self { - Self { - tx_name: tx_name.into(), - opcode: opcode.into(), - expected_gas_cost: gas_cost, - } - } -} - -impl Action for AssertLastOpcodeGasCost { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { - eyre::eyre!("Transaction '{}' not found in environment", self.tx_name) - })?; - - info!( - name = %self.tx_name, - tx_hash = %tx_hash, - opcode = %self.opcode, - expected_gas_cost = self.expected_gas_cost, - "Calling debug_traceTransaction with struct logger" - ); - - let client = env - .node() - .rpc_client() - .ok_or_else(|| eyre::eyre!("RPC client not available"))?; - - let opts = GethDebugTracingOptions { - config: GethDefaultTracingOptions::default() - .with_enable_memory(false) - .disable_stack() - .disable_storage(), - ..Default::default() - }; - - let trace = >::debug_trace_transaction(&client, tx_hash, Some(opts)) - .await - .map_err(|e| { - eyre::eyre!( - "debug_traceTransaction failed for tx '{}' ({}): {}", - self.tx_name, - tx_hash, - e - ) - })?; - - let GethTrace::Default(frame) = trace else { - return Err(eyre::eyre!( - "Expected default struct-log trace for tx '{}'", - self.tx_name - )); - }; - - let Some(log) = frame - .struct_logs - .iter() - .rev() - .find(|log| log.opcode() == self.opcode) - else { - return Err(eyre::eyre!( - "Tx '{}': opcode '{}' not found in trace", - self.tx_name, - self.opcode - )); - }; - - if log.gas_cost != self.expected_gas_cost { - return Err(eyre::eyre!( - "Tx '{}': last '{}' gas cost mismatch. Expected {}, got {}", - self.tx_name, - self.opcode, - self.expected_gas_cost, - log.gas_cost - )); - } - - info!( - name = %self.tx_name, - opcode = %self.opcode, - gas_cost = log.gas_cost, - "Opcode gas-cost assertion passed" - ); - - Ok(()) - }) - } -} diff --git a/crates/execution-e2e/src/actions/assertions.rs b/crates/execution-e2e/src/actions/assertions.rs deleted file mode 100644 index d3dd3447..00000000 --- a/crates/execution-e2e/src/actions/assertions.rs +++ /dev/null @@ -1,590 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Assertion actions for Arc e2e tests. - -use crate::{action::Action, ArcEnvironment}; -use alloy_consensus::TxReceipt; -use alloy_eips::Encodable2718; -use alloy_primitives::{Address, U256}; -use alloy_rpc_types_eth::BlockNumberOrTag; -use arc_execution_config::hardforks::{is_arc_fork_active, ArcHardfork}; -use futures_util::future::BoxFuture; -use reth_chainspec::{ChainSpecProvider, EthereumHardfork, EthereumHardforks}; -use reth_node_api::Block; -use reth_provider::{BlockReaderIdExt, ReceiptProvider}; -use reth_rpc_api::EthApiClient; -use tracing::info; - -/// Asserts that the current block number matches the expected value. -#[derive(Debug)] -pub struct AssertBlockNumber { - /// Expected block number. - expected: u64, -} - -impl AssertBlockNumber { - /// Creates a new AssertBlockNumber action. - pub fn new(expected: u64) -> Self { - Self { expected } - } -} - -impl Action for AssertBlockNumber { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let current = env.block_number(); - info!( - current_block = current, - expected_block = self.expected, - "Asserting block number" - ); - - if current != self.expected { - return Err(eyre::eyre!( - "Block number mismatch: expected {}, got {}", - self.expected, - current - )); - } - - info!(block_number = current, "Block number assertion passed"); - Ok(()) - }) - } -} - -/// Asserts that a specific hardfork is active (or not active) at the current block. -#[derive(Debug)] -pub struct AssertHardfork { - /// The hardfork to check. - hardfork: ArcHardfork, - /// Whether the hardfork should be active. - expected_active: bool, -} - -impl AssertHardfork { - /// Creates a new AssertHardfork action. - /// - /// # Arguments - /// * `hardfork` - The hardfork to check - /// * `expected_active` - Whether the hardfork should be active at current block - pub fn new(hardfork: ArcHardfork, expected_active: bool) -> Self { - Self { - hardfork, - expected_active, - } - } - - /// Asserts that the hardfork IS active at current block. - pub fn is_active(hardfork: ArcHardfork) -> Self { - Self::new(hardfork, true) - } - - /// Asserts that the hardfork is NOT active at current block. - pub fn is_not_active(hardfork: ArcHardfork) -> Self { - Self::new(hardfork, false) - } -} - -impl Action for AssertHardfork { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let block = env.current_block(); - let block_number = block.number; - let block_timestamp = block.timestamp; - let chain_spec = env.node().inner.provider().chain_spec(); - // An Arc hardfork can be configured as either Block or Timestamp, - // so check both — the underlying `is_fork_active_at_*` returns false - // for the wrong condition type, and we OR them together. - let is_active = is_arc_fork_active( - chain_spec.as_ref(), - self.hardfork, - block_number, - block_timestamp, - ); - - info!( - hardfork = ?self.hardfork, - block_number, - block_timestamp, - is_active, - expected_active = self.expected_active, - "Asserting hardfork status" - ); - - if is_active != self.expected_active { - return Err(eyre::eyre!( - "Hardfork {:?} at block {} (ts {}): expected active={}, got active={}", - self.hardfork, - block_number, - block_timestamp, - self.expected_active, - is_active - )); - } - - info!( - hardfork = ?self.hardfork, - block_number, - "Hardfork assertion passed" - ); - Ok(()) - }) - } -} - -/// Asserts that a timestamp-based Ethereum hardfork is active (or not active) at the current block. -/// -/// Unlike `AssertHardfork` which checks block-based Arc hardforks, this checks -/// timestamp-based Ethereum hardforks like Osaka. -#[derive(Debug)] -pub struct AssertEthereumHardfork { - /// The Ethereum hardfork to check. - hardfork: EthereumHardfork, - /// Whether the hardfork should be active. - expected_active: bool, -} - -impl AssertEthereumHardfork { - /// Asserts that the Ethereum hardfork IS active at the current block's timestamp. - pub fn is_active(hardfork: EthereumHardfork) -> Self { - Self { - hardfork, - expected_active: true, - } - } - - /// Asserts that the Ethereum hardfork is NOT active at the current block's timestamp. - pub fn is_not_active(hardfork: EthereumHardfork) -> Self { - Self { - hardfork, - expected_active: false, - } - } -} - -impl Action for AssertEthereumHardfork { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let timestamp = env.current_block().timestamp; - let chain_spec = env.node().inner.provider().chain_spec(); - let is_active = - chain_spec.is_ethereum_fork_active_at_timestamp(self.hardfork, timestamp); - - info!( - hardfork = ?self.hardfork, - timestamp, - is_active, - expected_active = self.expected_active, - "Asserting Ethereum hardfork status" - ); - - if is_active != self.expected_active { - return Err(eyre::eyre!( - "Ethereum hardfork {:?} at timestamp {}: expected active={}, got active={}", - self.hardfork, - timestamp, - self.expected_active, - is_active - )); - } - - info!( - hardfork = ?self.hardfork, - timestamp, - "Ethereum hardfork assertion passed" - ); - Ok(()) - }) - } -} - -/// Expected transaction execution status. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum TxStatus { - /// Transaction should succeed (status = 1). - #[default] - Success, - /// Transaction should revert (status = 0). - Reverted, -} - -/// Asserts that a transaction was included in a block and checks its execution status. -/// -/// The transaction name must match one previously sent via `SendTransaction::new("name")`. -/// Optionally specify a block number with `in_block()` to check a specific block instead -/// of the current block. -#[derive(Debug)] -pub struct AssertTxIncluded { - /// Name of the transaction to look up in `env.tx_hashes`. - name: String, - /// Expected execution status (success or reverted). - expected_status: TxStatus, - /// Expected gas used. - expected_gas_used: Option, - /// Specific block number to check. If None, uses current block. - block_number: Option, -} - -impl AssertTxIncluded { - /// Creates a new assertion for a transaction. - /// - /// The name must match a transaction previously sent via `SendTransaction::new("name")`. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - expected_status: TxStatus::default(), - expected_gas_used: None, - block_number: None, - } - } - - /// Sets the expected transaction execution status. - pub fn expect(mut self, expected_status: TxStatus) -> Self { - self.expected_status = expected_status; - self - } - - /// Sets the expected transaction gas used. - pub fn expect_gas_used(mut self, expected_gas_used: u64) -> Self { - self.expected_gas_used = Some(expected_gas_used); - self - } - - /// Sets a specific block number to check instead of using the current block. - pub fn in_block(mut self, block_number: u64) -> Self { - self.block_number = Some(block_number); - self - } -} - -impl Action for AssertTxIncluded { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - // Look up transaction hash by name - let tx_hash = *env.get_tx_hash(&self.name).ok_or_else(|| { - eyre::eyre!("Transaction '{}' not found in environment", self.name) - })?; - - // Use specified block number or current block - let block_number = self.block_number.unwrap_or_else(|| env.block_number()); - - info!( - name = %self.name, - tx_hash = %tx_hash, - block_number, - expected_status = ?self.expected_status, - "Asserting transaction inclusion and status" - ); - - // Get the block from the provider - let node = env.node(); - let block = node - .inner - .provider() - .block_by_number_or_tag(BlockNumberOrTag::Number(block_number))? - .ok_or_else(|| eyre::eyre!("Block {} not found", block_number))?; - - // Check if the transaction is in the block - let tx_hashes: Vec<_> = block - .body() - .transactions() - .map(|tx| tx.trie_hash()) - .collect(); - - let tx_index = tx_hashes - .iter() - .position(|h| *h == tx_hash) - .ok_or_else(|| { - eyre::eyre!( - "Transaction '{}' ({}) not found in block {}, Block contains {} transactions: {:?}", - self.name, - tx_hash, - block_number, - tx_hashes.len(), - tx_hashes - ) - })?; - - // Get the receipt to check execution status - let receipt = node - .inner - .provider() - .receipt_by_hash(tx_hash)? - .ok_or_else(|| { - eyre::eyre!( - "Receipt not found for transaction '{}' ({})", - self.name, - tx_hash - ) - })?; - - let actual_status = if receipt.status() { - TxStatus::Success - } else { - TxStatus::Reverted - }; - - if actual_status != self.expected_status { - return Err(eyre::eyre!( - "Transaction '{}' ({}) status mismatch: expected {:?}, got {:?}", - self.name, - tx_hash, - self.expected_status, - actual_status - )); - } - if let Some(expected_gas_used) = self.expected_gas_used { - // Consensus receipts only store cumulative gas in the block. Per-tx gas (what - // JSON-RPC reports as `gasUsed`) is the delta from the previous tx in the same - // block, not `cumulative_gas_used` on the receipt alone. - let prev_cumulative_gas_used = if tx_index == 0 { - 0 - } else { - let prev_tx_hash = tx_hashes[tx_index - 1]; - node.inner - .provider() - .receipt_by_hash(prev_tx_hash)? - .ok_or_else(|| { - eyre::eyre!( - "Previous receipt not found for transaction '{}' ({})", - self.name, - prev_tx_hash - ) - })? - .cumulative_gas_used() - }; - let cumulative_gas_used = receipt.cumulative_gas_used(); - let actual_gas_used = cumulative_gas_used - .checked_sub(prev_cumulative_gas_used) - .ok_or_else(|| { - eyre::eyre!( - "Cumulative gas regression for tx '{}' ({}): \ - prev_cumulative={}, current_cumulative={}", - self.name, - tx_hash, - prev_cumulative_gas_used, - cumulative_gas_used, - ) - })?; - if actual_gas_used != expected_gas_used { - return Err(eyre::eyre!( - "Transaction '{}' ({}) gas used mismatch: expected {}, got {}", - self.name, - tx_hash, - expected_gas_used, - actual_gas_used - )); - } - } - - info!( - name = %self.name, - tx_hash = %tx_hash, - block_number, - block_hash = %block.header().hash_slow(), - status = ?actual_status, - "Transaction inclusion and status assertion passed" - ); - - Ok(()) - }) - } -} - -/// Asserts that a transaction was NOT included in a block. -/// -/// Useful for verifying that a transaction was rejected during block building -/// (e.g., due to insufficient gas for Arc-specific intrinsic gas costs). -/// -/// The transaction name must match one previously sent via `SendTransaction::new("name")`. -/// Optionally specify a block number with `in_block()` to check a specific block instead -/// of the current block. -#[derive(Debug)] -pub struct AssertTxNotIncluded { - /// Name of the transaction to look up in `env.tx_hashes`. - name: String, - /// Specific block number to check. If None, uses current block. - block_number: Option, -} - -impl AssertTxNotIncluded { - /// Creates a new assertion that a transaction should NOT be in a block. - /// - /// The name must match a transaction previously sent via `SendTransaction::new("name")`. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - block_number: None, - } - } - - /// Sets a specific block number to check instead of using the current block. - pub fn in_block(mut self, block_number: u64) -> Self { - self.block_number = Some(block_number); - self - } -} - -impl Action for AssertTxNotIncluded { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let tx_hash = *env.get_tx_hash(&self.name).ok_or_else(|| { - eyre::eyre!("Transaction '{}' not found in environment", self.name) - })?; - - let block_number = self.block_number.unwrap_or_else(|| env.block_number()); - - info!( - name = %self.name, - tx_hash = %tx_hash, - block_number, - "Asserting transaction NOT included in block" - ); - - let node = env.node(); - let block = node - .inner - .provider() - .block_by_number_or_tag(BlockNumberOrTag::Number(block_number))? - .ok_or_else(|| eyre::eyre!("Block {} not found", block_number))?; - - let tx_hashes: Vec<_> = block - .body() - .transactions() - .map(|tx| tx.trie_hash()) - .collect(); - - if tx_hashes.contains(&tx_hash) { - return Err(eyre::eyre!( - "Transaction '{}' ({}) was found in block {} but should NOT have been included", - self.name, - tx_hash, - block_number, - )); - } - - info!( - name = %self.name, - tx_hash = %tx_hash, - block_number, - "Transaction exclusion assertion passed" - ); - - Ok(()) - }) - } -} - -/// Asserts that an address has a specific balance via `eth_getBalance` RPC. -/// -/// Supports exact match, minimum bound, and range (minimum + maximum) checks. -#[derive(Debug)] -pub struct AssertBalance { - address: Address, - expected: U256, - /// If set, asserts `balance >= expected` instead of exact equality. - at_least: bool, - /// Upper bound for range checks: `balance <= max`. - max: Option, -} - -impl AssertBalance { - /// Creates a new exact balance assertion. - pub fn new(address: Address, expected: U256) -> Self { - Self { - address, - expected, - at_least: false, - max: None, - } - } - - /// Asserts that the balance is at least `expected`. - pub fn at_least(mut self) -> Self { - self.at_least = true; - self - } - - /// Sets an upper bound and enables range mode: asserts `expected <= balance <= max`. - pub fn at_most(mut self, max: U256) -> Self { - self.at_least = true; - self.max = Some(max); - self - } -} - -impl Action for AssertBalance { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let client = env - .node() - .rpc_client() - .ok_or_else(|| eyre::eyre!("RPC client not available"))?; - - let balance = >::balance(&client, self.address, None) - .await?; - - info!( - address = %self.address, - balance = %balance, - expected = %self.expected, - at_least = self.at_least, - "Asserting balance" - ); - - if self.at_least { - if balance < self.expected { - return Err(eyre::eyre!( - "Balance of {} too low: expected >= {}, got {}", - self.address, - self.expected, - balance - )); - } - } else if balance != self.expected { - return Err(eyre::eyre!( - "Balance mismatch for {}: expected {}, got {}", - self.address, - self.expected, - balance - )); - } - - if let Some(max) = self.max { - if balance > max { - return Err(eyre::eyre!( - "Balance of {} too high: expected <= {}, got {}", - self.address, - max, - balance - )); - } - } - - info!(address = %self.address, balance = %balance, "Balance assertion passed"); - Ok(()) - }) - } -} diff --git a/crates/execution-e2e/src/actions/call_contract.rs b/crates/execution-e2e/src/actions/call_contract.rs deleted file mode 100644 index 3d548e14..00000000 --- a/crates/execution-e2e/src/actions/call_contract.rs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Contract call actions for Arc e2e tests. -//! -//! Provides actions to execute eth_call and verify return values. -//! This is useful for testing precompiles and read-only contract calls -//! without submitting transactions. - -use crate::{action::Action, ArcEnvironment}; -use alloy_primitives::{Address, Bytes, TxKind}; -use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; -use futures_util::future::BoxFuture; -use reth_rpc_api::EthApiClient; -use tracing::info; - -/// Executes an eth_call to a contract or precompile and optionally verifies the result. -/// -/// This action: -/// 1. Executes an eth_call (read-only, no transaction submitted) -/// 2. Optionally verifies the return value matches expected -/// 3. Can be configured to expect the call to revert -/// 4. Can be configured to expect a non-zero result -/// -/// # Example -/// -/// ```ignore -/// use alloy_primitives::{address, bytes}; -/// -/// ArcTestBuilder::new() -/// .with_setup(ArcSetup::new()) -/// .with_action(ProduceBlocks::new(1)) -/// .with_action( -/// CallContract::new("my_call") -/// .to(address!("0000000000000000000000000000000000000100")) -/// .with_data(bytes!("...")) -/// .expect_result(bytes!("0000000000000000000000000000000000000000000000000000000000000001")) -/// ) -/// .run() -/// .await -/// ``` -#[derive(Debug)] -pub struct CallContract { - /// Name to reference this call in logs and errors. - name: String, - /// Target address (contract or precompile). - to: Address, - /// Call data to send. - data: Bytes, - /// Expected return value. If Some, will assert equality. - expected_result: Option, - /// If true, expect the call to fail/revert. - expect_revert: bool, - /// If true, expect the result to be non-zero (non-empty and not all zeros). - expect_non_zero: bool, -} - -impl CallContract { - /// Creates a new CallContract action with the given name. - /// - /// The name is used for logging and error messages. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - to: Address::ZERO, - data: Bytes::new(), - expected_result: None, - expect_revert: false, - expect_non_zero: false, - } - } - - /// Sets the target address for the call. - pub fn to(mut self, address: Address) -> Self { - self.to = address; - self - } - - /// Sets the call data. - pub fn with_data(mut self, data: Bytes) -> Self { - self.data = data; - self - } - - /// Sets the expected return value. - /// - /// The action will fail if the actual result doesn't match. - pub fn expect_result(mut self, result: Bytes) -> Self { - self.expected_result = Some(result); - self - } - - /// Configures the action to expect the call to revert. - /// - /// The action will fail if the call succeeds. - pub fn expect_revert(mut self) -> Self { - self.expect_revert = true; - self - } - - /// Configures the action to expect a non-zero result. - /// - /// The action will fail if the result is empty or all zeros. - pub fn expect_non_zero_result(mut self) -> Self { - self.expect_non_zero = true; - self - } -} - -impl Action for CallContract { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - info!( - name = %self.name, - to = %self.to, - data_len = self.data.len(), - expect_revert = self.expect_revert, - "Executing eth_call" - ); - - // Get RPC client from node - let client = env - .node() - .rpc_client() - .ok_or_else(|| eyre::eyre!("RPC client not available"))?; - - // Build transaction request - let request = TransactionRequest { - to: Some(TxKind::Call(self.to)), - input: TransactionInput::new(self.data.clone()), - ..Default::default() - }; - - // Execute eth_call using the EthApiClient trait - // HttpClient implements EthApiClient with these type parameters - let result = >::call(&client, request, None, None, None) - .await; - - match result { - Ok(output) => { - if self.expect_revert { - return Err(eyre::eyre!( - "Call '{}' succeeded but was expected to revert. Output: {}", - self.name, - output - )); - } - - info!( - name = %self.name, - output_len = output.len(), - output = %output, - "eth_call succeeded" - ); - - // Verify expected result if provided - if let Some(expected) = &self.expected_result { - if output != *expected { - return Err(eyre::eyre!( - "Call '{}' result mismatch.\nExpected: {}\nActual: {}", - self.name, - expected, - output - )); - } - info!(name = %self.name, "Result matches expected value"); - } - - // Verify non-zero result if configured - if self.expect_non_zero { - if output.is_empty() || output.iter().all(|&b| b == 0) { - return Err(eyre::eyre!( - "Call '{}' returned zero/empty result but expected non-zero.\nActual: {}", - self.name, - output - )); - } - info!(name = %self.name, "Result is non-zero as expected"); - } - - Ok(()) - } - Err(err) => { - if self.expect_revert { - info!( - name = %self.name, - error = %err, - "eth_call reverted as expected" - ); - Ok(()) - } else { - Err(eyre::eyre!( - "Call '{}' failed unexpectedly: {}", - self.name, - err - )) - } - } - } - }) - } -} diff --git a/crates/execution-e2e/src/actions/mod.rs b/crates/execution-e2e/src/actions/mod.rs deleted file mode 100644 index 364c8b96..00000000 --- a/crates/execution-e2e/src/actions/mod.rs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Actions for Arc e2e tests. -//! -//! Actions are composable building blocks for test scenarios. - -mod assert_named; -mod assert_tx_logs; -mod assert_tx_trace; -mod assertions; -mod call_contract; -mod payload_utils; -mod produce_blocks; -mod produce_invalid_block; -mod send_transaction; -mod store_deployed_address; - -pub use assert_named::{AssertNamedBalance, AssertTransferEvent}; -pub use assert_tx_logs::{ - AssertTxLogs, NATIVE_COIN_TRANSFERRED_SIGNATURE, TRANSFER_EVENT_SIGNATURE, -}; -pub use assert_tx_trace::{AssertLastOpcodeGasCost, AssertTxTrace}; -pub use assertions::{ - AssertBalance, AssertBlockNumber, AssertEthereumHardfork, AssertHardfork, AssertTxIncluded, - AssertTxNotIncluded, TxStatus, -}; -pub use call_contract::CallContract; -pub use payload_utils::{ - assert_valid_or_syncing, build_payload_for_next_block, set_payload_override_and_rehash, - submit_payload, -}; -pub use produce_blocks::ProduceBlocks; -pub use produce_invalid_block::ProduceInvalidBlock; -pub use send_transaction::SendTransaction; -pub use store_deployed_address::StoreDeployedAddress; diff --git a/crates/execution-e2e/src/actions/payload_utils.rs b/crates/execution-e2e/src/actions/payload_utils.rs deleted file mode 100644 index 8e0a0fff..00000000 --- a/crates/execution-e2e/src/actions/payload_utils.rs +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Shared payload utilities for e2e tests and actions. - -use crate::ArcEnvironment; -use alloy_eips::eip7685::{Requests, RequestsOrHash}; -use alloy_primitives::{address, B256}; -use alloy_rpc_types_engine::{ - CancunPayloadFields, ExecutionData, ExecutionPayload, ExecutionPayloadSidecar, - ExecutionPayloadV1, ExecutionPayloadV3, ForkchoiceState, PayloadAttributes, PayloadStatusEnum, - PraguePayloadFields, -}; -use reth_ethereum::node::EthEngineTypes; -use reth_rpc_api::clients::EngineApiClient; - -/// JSON-RPC error code for "Unsupported Fork" per the Engine API spec. -const UNSUPPORTED_FORK_CODE: i32 = -38005; - -/// Returns `true` if the error is a JSON-RPC "Unsupported Fork" (-38005) response. -pub(crate) fn is_unsupported_fork_err(err: &jsonrpsee::core::client::Error) -> bool { - matches!(err, jsonrpsee::core::client::Error::Call(obj) if obj.code() == UNSUPPORTED_FORK_CODE) -} - -/// Builds a payload for the next block and returns: -/// `(execution_payload, execution_requests, parent_beacon_block_root)`. -pub async fn build_payload_for_next_block( - env: &ArcEnvironment, -) -> eyre::Result<(ExecutionPayloadV3, Requests, B256)> { - build_payload_for_next_block_with_client( - &env.node().inner.auth_server_handle().http_client(), - env.current_block(), - ) - .await -} - -/// Builds a payload for the next block using the given engine client and parent block info. -/// Used when the node is created outside ArcEnvironment (e.g. NodeBuilder with custom config). -pub(crate) async fn build_payload_for_next_block_with_client>( - engine_client: &C, - current_block: &crate::environment::BlockInfo, -) -> eyre::Result<(ExecutionPayloadV3, Requests, B256)> { - let parent_hash = current_block.hash; - let parent_beacon_block_root = B256::ZERO; - - let fork_choice_state = ForkchoiceState { - head_block_hash: parent_hash, - safe_block_hash: parent_hash, - finalized_block_hash: parent_hash, - }; - - let payload_attributes = PayloadAttributes { - timestamp: current_block.timestamp + 1, - prev_randao: B256::random(), - suggested_fee_recipient: address!("0x65E0a200006D4FF91bD59F9694220dafc49dbBC1"), - withdrawals: Some(vec![]), - parent_beacon_block_root: Some(parent_beacon_block_root), - }; - - let fcu_result = EngineApiClient::::fork_choice_updated_v3( - engine_client, - fork_choice_state, - Some(payload_attributes), - ) - .await?; - assert_valid_or_syncing( - &fcu_result.payload_status.status, - "forkChoiceUpdated while building payload", - )?; - - let payload_id = fcu_result - .payload_id - .ok_or_else(|| eyre::eyre!("No payload ID returned from forkChoiceUpdated"))?; - - // Use getPayloadV5 (Osaka) if supported, otherwise fall back to V4 (Prague). - // V5 is required when Osaka is active; V4 is rejected post-Osaka. - let (execution_payload, execution_requests) = - match EngineApiClient::::get_payload_v5(engine_client, payload_id).await { - Ok(envelope) => ( - envelope.execution_payload.clone(), - envelope.execution_requests.clone(), - ), - Err(e) => { - if !is_unsupported_fork_err(&e) { - return Err(eyre::eyre!("getPayloadV5 failed: {e}")); - } - let envelope = - EngineApiClient::::get_payload_v4(engine_client, payload_id) - .await?; - ( - envelope.execution_payload.clone(), - envelope.execution_requests.clone(), - ) - } - }; - - Ok(( - execution_payload, - execution_requests, - parent_beacon_block_root, - )) -} - -/// Mutates payload and recomputes block hash with full sidecar context. -pub fn set_payload_override_and_rehash( - payload: &mut ExecutionPayloadV3, - execution_requests: &Requests, - parent_beacon_block_root: B256, - payload_override: ExecutionPayloadV1, -) -> eyre::Result<()> { - payload.payload_inner.payload_inner = payload_override; - - let sidecar = ExecutionPayloadSidecar::v4( - CancunPayloadFields::new(parent_beacon_block_root, vec![]), - PraguePayloadFields::new(execution_requests.clone()), - ); - payload.payload_inner.payload_inner.block_hash = - ExecutionData::new(ExecutionPayload::V3(payload.clone()), sidecar) - .into_block_raw()? - .hash_slow(); - - Ok(()) -} - -/// Submits a payload via `engine_newPayloadV4` and returns the status. -pub async fn submit_payload( - env: &ArcEnvironment, - payload: ExecutionPayloadV3, - execution_requests: Requests, - parent_beacon_block_root: B256, -) -> eyre::Result { - submit_payload_with_client( - &env.node().inner.auth_server_handle().http_client(), - payload, - execution_requests, - parent_beacon_block_root, - ) - .await -} - -/// Submits a payload via `engine_newPayloadV4` using the given engine client. -/// Used when the node is created outside ArcEnvironment (e.g. NodeBuilder with custom config). -pub(crate) async fn submit_payload_with_client>( - engine_client: &C, - payload: ExecutionPayloadV3, - execution_requests: Requests, - parent_beacon_block_root: B256, -) -> eyre::Result { - let result = EngineApiClient::::new_payload_v4( - engine_client, - payload, - vec![], - parent_beacon_block_root, - RequestsOrHash::Requests(execution_requests), - ) - .await; - - match result { - Ok(response) => Ok(response.status), - Err(err) => Err(err.into()), - } -} - -/// Validates that payload status is either VALID or SYNCING. -pub fn assert_valid_or_syncing(status: &PayloadStatusEnum, context: &str) -> eyre::Result<()> { - match status { - PayloadStatusEnum::Valid | PayloadStatusEnum::Syncing => Ok(()), - PayloadStatusEnum::Invalid { validation_error } => Err(eyre::eyre!( - "{context} returned INVALID: {validation_error}" - )), - status => Err(eyre::eyre!( - "{context} returned unexpected status: {:?}", - status - )), - } -} diff --git a/crates/execution-e2e/src/actions/produce_blocks.rs b/crates/execution-e2e/src/actions/produce_blocks.rs deleted file mode 100644 index ec8c2366..00000000 --- a/crates/execution-e2e/src/actions/produce_blocks.rs +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Block production actions for Arc e2e tests. -//! -//! Uses Engine API directly to produce blocks, allowing for empty blocks -//! which is common in testing scenarios. - -use super::{assert_valid_or_syncing, build_payload_for_next_block, submit_payload}; -use crate::{action::Action, environment::BlockInfo, ArcEnvironment}; -use alloy_rpc_types_engine::ForkchoiceState; -use futures_util::future::BoxFuture; -use reth_ethereum::node::EthEngineTypes; -use reth_rpc_api::clients::EngineApiClient; -use tracing::{debug, info}; - -/// Produces a specified number of blocks using Engine API. -/// -/// For each block: -/// 1. Sends forkchoiceUpdated with payload attributes to start building -/// 2. Retrieves payload via getPayload -/// 3. Submits payload via newPayload -/// 4. Updates forkchoice to finalize the block -/// 5. Updates the environment's current block info -#[derive(Debug)] -pub struct ProduceBlocks { - /// Number of blocks to produce. - num_blocks: u64, -} - -impl ProduceBlocks { - /// Creates a new ProduceBlocks action. - pub fn new(num_blocks: u64) -> Self { - Self { num_blocks } - } -} - -impl Action for ProduceBlocks { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let starting_block = env.block_number(); - info!( - starting_block, - num_blocks = self.num_blocks, - "Producing blocks via Engine API" - ); - - for i in 0..self.num_blocks { - let current_block = env.current_block().clone(); - let parent_hash = current_block.hash; - - debug!( - parent_hash = %parent_hash, - parent_number = current_block.number, - "Building block on parent" - ); - - // Step 1-2: Build payload via Engine API (FCU + getPayload) - let (execution_payload, execution_requests, parent_beacon_block_root) = - build_payload_for_next_block(env).await?; - let block_hash = execution_payload.payload_inner.payload_inner.block_hash; - let block_number = execution_payload.payload_inner.payload_inner.block_number; - let block_timestamp = execution_payload.payload_inner.payload_inner.timestamp; - - debug!( - block_hash = %block_hash, - block_number, - "Got built payload" - ); - - // Step 3: Submit the payload - let new_payload_status = submit_payload( - env, - execution_payload, - execution_requests, - parent_beacon_block_root, - ) - .await?; - - debug!("newPayload status: {:?}", new_payload_status); - assert_valid_or_syncing(&new_payload_status, "newPayload")?; - - // Get the auth server handle from the node - let node = env.node(); - let auth_server = node.inner.auth_server_handle(); - let engine_client = auth_server.http_client(); - - // Step 4: Update forkchoice to make the new block canonical and finalized - let new_fork_choice = ForkchoiceState { - head_block_hash: block_hash, - safe_block_hash: block_hash, - finalized_block_hash: block_hash, - }; - - let finalize_result = EngineApiClient::::fork_choice_updated_v3( - &engine_client, - new_fork_choice, - None, - ) - .await?; - - debug!("Finalize FCU result: {:?}", finalize_result); - assert_valid_or_syncing(&finalize_result.payload_status.status, "Finalize FCU")?; - - // Step 5: Update environment's current block - env.set_current_block(BlockInfo::new(block_hash, block_number, block_timestamp)); - - info!( - block_number, - block_hash = %block_hash, - iteration = i + 1, - "Produced block" - ); - } - - let final_block = env.block_number(); - info!( - starting_block, - final_block, - blocks_produced = self.num_blocks, - "Finished producing blocks" - ); - - Ok(()) - }) - } -} diff --git a/crates/execution-e2e/src/actions/produce_invalid_block.rs b/crates/execution-e2e/src/actions/produce_invalid_block.rs deleted file mode 100644 index ad37aeeb..00000000 --- a/crates/execution-e2e/src/actions/produce_invalid_block.rs +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Block production with invalid payloads for bad validator simulation. -//! -//! This module provides actions to produce blocks with corrupted fields -//! to test how the execution layer handles invalid blocks. - -use crate::{action::Action, actions::payload_utils::is_unsupported_fork_err, ArcEnvironment}; -use alloy_eips::eip7685::RequestsOrHash; -use alloy_primitives::B256; -use alloy_rpc_types_engine::{ForkchoiceState, PayloadAttributes, PayloadStatusEnum}; -use futures_util::future::BoxFuture; -use reth_ethereum::node::EthEngineTypes; -use reth_rpc_api::clients::EngineApiClient; -use tracing::{debug, info}; - -/// Produces a block with a corrupted state root. -/// -/// This action simulates bad validator behavior by: -/// 1. Building a normal payload via forkchoiceUpdated + getPayload -/// 2. Corrupting the state root to a random value -/// 3. Submitting the corrupted payload via newPayload -/// 4. Expecting the payload to be rejected as INVALID -#[derive(Debug, Default)] -pub struct ProduceInvalidBlock; - -impl ProduceInvalidBlock { - /// Create a new action that produces a block with a corrupted state root. - pub fn new() -> Self { - Self - } -} - -impl Action for ProduceInvalidBlock { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let current_block = env.current_block().clone(); - let parent_hash = current_block.hash; - let parent_timestamp = current_block.timestamp; - - info!( - parent_hash = %parent_hash, - parent_number = current_block.number, - "Producing invalid block with corrupted state root" - ); - - // Get the auth server handle from the node - let node = env.node(); - let auth_server = node.inner.auth_server_handle(); - let engine_client = auth_server.http_client(); - - // Create forkchoice state pointing to current head - let fork_choice_state = ForkchoiceState { - head_block_hash: parent_hash, - safe_block_hash: parent_hash, - finalized_block_hash: parent_hash, - }; - - // Create payload attributes for the next block - let next_timestamp = parent_timestamp + 1; - let payload_attributes = PayloadAttributes { - timestamp: next_timestamp, - prev_randao: B256::random(), - suggested_fee_recipient: alloy_primitives::address!( - "0x65E0a200006D4FF91bD59F9694220dafc49dbBC1" - ), - withdrawals: Some(vec![]), - parent_beacon_block_root: Some(B256::ZERO), - }; - - // Step 1: Send FCU with payload attributes to start building - let fcu_result = EngineApiClient::::fork_choice_updated_v3( - &engine_client, - fork_choice_state, - Some(payload_attributes), - ) - .await?; - - debug!("FCU result: {:?}", fcu_result); - - match &fcu_result.payload_status.status { - PayloadStatusEnum::Valid | PayloadStatusEnum::Syncing => {} - PayloadStatusEnum::Invalid { validation_error } => { - return Err(eyre::eyre!( - "FCU returned Invalid status: {:?}", - validation_error - )); - } - status => { - return Err(eyre::eyre!("Unexpected FCU status: {:?}", status)); - } - } - - let payload_id = fcu_result - .payload_id - .ok_or_else(|| eyre::eyre!("No payload ID returned from FCU"))?; - - debug!("Got payload ID: {:?}", payload_id); - - // Step 2: Get the built payload - // Use getPayloadV5 (Osaka) if supported, otherwise fall back to V4 (Prague). - // V5 is required when Osaka is active; V4 is rejected post-Osaka. - let (execution_payload, execution_requests) = - match EngineApiClient::::get_payload_v5(&engine_client, payload_id) - .await - { - Ok(envelope) => ( - envelope.execution_payload.clone(), - envelope.execution_requests.clone(), - ), - Err(e) => { - if !is_unsupported_fork_err(&e) { - return Err(eyre::eyre!("getPayloadV5 failed: {e}")); - } - let envelope = EngineApiClient::::get_payload_v4( - &engine_client, - payload_id, - ) - .await?; - ( - envelope.execution_payload.clone(), - envelope.execution_requests.clone(), - ) - } - }; - - let mut corrupted_payload = execution_payload; - let block_hash = corrupted_payload.payload_inner.payload_inner.block_hash; - let block_number = corrupted_payload.payload_inner.payload_inner.block_number; - - debug!( - block_hash = %block_hash, - block_number, - "Got built payload, applying corruption" - ); - - // Step 3: Corrupt the state root - let original = corrupted_payload.payload_inner.payload_inner.state_root; - corrupted_payload.payload_inner.payload_inner.state_root = B256::random(); - info!( - original_state_root = %original, - corrupted_state_root = %corrupted_payload.payload_inner.payload_inner.state_root, - "Corrupted state root" - ); - - // Step 4: Submit the corrupted payload - let new_payload_result = EngineApiClient::::new_payload_v4( - &engine_client, - corrupted_payload, - vec![], - B256::ZERO, - RequestsOrHash::Requests(execution_requests), - ) - .await?; - - debug!("newPayload result: {:?}", new_payload_result); - - // Step 5: Expect the payload to be rejected as invalid - match &new_payload_result.status { - PayloadStatusEnum::Invalid { validation_error } => { - info!( - ?validation_error, - block_number, "Block correctly rejected as invalid" - ); - Ok(()) - } - status => Err(eyre::eyre!( - "Expected newPayload to return INVALID, but got {:?}", - status - )), - } - }) - } -} diff --git a/crates/execution-e2e/src/actions/send_transaction.rs b/crates/execution-e2e/src/actions/send_transaction.rs deleted file mode 100644 index f6baf851..00000000 --- a/crates/execution-e2e/src/actions/send_transaction.rs +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Transaction sending actions for Arc e2e tests. -//! -//! Provides actions to send EIP-1559 transactions to the node's transaction pool -//! via direct pool injection. - -use crate::{action::Action, ArcEnvironment}; -use alloy_network::eip2718::{Decodable2718, Encodable2718}; -use alloy_primitives::{Address, Bytes, TxHash, TxKind, U256}; -use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; -use futures_util::future::BoxFuture; -use reth_e2e_test_utils::transaction::TransactionTestContext; -use reth_ethereum_primitives::TransactionSigned; -use reth_primitives_traits::SignerRecoverable; -use reth_transaction_pool::{TransactionOrigin, TransactionPool}; -use tracing::{debug, info}; - -/// Closure that resolves calldata from the environment at execution time. -type DataResolver = Box eyre::Result + Send + Sync>; - -/// Sends an EIP-1559 transaction to the node's transaction pool. -/// -/// This action: -/// 1. Creates an EIP-1559 transaction from a wallet -/// 2. Signs and submits it directly to the transaction pool -/// 3. Stores the transaction hash under the given name for later assertions -/// -/// Supports both CALL (with `to` address) and CREATE (without `to`, using `with_create()`) -/// transactions. For CREATE transactions, the deployed contract address is extracted -/// from the receipt and stored under `"{name}_address"` in the environment. -pub struct SendTransaction { - /// Name to reference this transaction in assertions. - name: String, - /// The value to transfer (in wei). - value: U256, - /// The recipient address. If None and not create, sends to a random address. - to: Option
, - /// Named address to look up at execution time (overrides `to` if set). - to_named: Option, - /// If true, this is a CREATE transaction (no `to` address). - create: bool, - /// Optional input data for the transaction. - data: Option, - /// Deferred calldata resolver — called at execution time with access to the environment. - data_resolver: Option, - /// Gas limit for the transaction. - gas_limit: u64, - /// Wallet index to sign from (default: 0 = first wallet). - wallet_index: usize, -} - -impl std::fmt::Debug for SendTransaction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SendTransaction") - .field("name", &self.name) - .field("value", &self.value) - .field("to", &self.to) - .field("to_named", &self.to_named) - .field("create", &self.create) - .field("data", &self.data) - .field("data_resolver", &self.data_resolver.as_ref().map(|_| "..")) - .field("gas_limit", &self.gas_limit) - .field("wallet_index", &self.wallet_index) - .finish() - } -} - -impl SendTransaction { - /// Creates a new named SendTransaction action with default values. - /// - /// The name is used to reference this transaction in assertions via - /// `AssertTxIncluded::new("name")`. - /// - /// Default is a simple 1 wei transfer to a random address. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - value: U256::from(1), - to: None, - to_named: None, - create: false, - data: None, - data_resolver: None, - gas_limit: 21000, - wallet_index: 0, - } - } - - /// Sets the value to transfer. - pub fn with_value(mut self, value: U256) -> Self { - self.value = value; - self - } - - /// Sets the recipient address. - pub fn with_to(mut self, to: Address) -> Self { - self.to = Some(to); - self - } - - /// Sets input data for the transaction (e.g., contract call). - pub fn with_data(mut self, data: Bytes) -> Self { - self.data = Some(data); - self - } - - /// Marks this as a CREATE transaction (no `to` address). - /// The `data` field should contain the deployment bytecode. - pub fn with_create(mut self) -> Self { - self.create = true; - self - } - - /// Sets the recipient to a named address stored in the environment. - /// - /// At execution time, looks up the address by name (e.g. `"deploy_address"` - /// for a contract deployed by a CREATE tx named `"deploy"`). - pub fn with_to_named(mut self, address_name: impl Into) -> Self { - self.to_named = Some(address_name.into()); - self - } - - /// Deferred calldata — the closure is called at execution time with access - /// to the environment, so it can resolve named addresses or other runtime state. - pub fn with_data_fn( - mut self, - f: impl Fn(&ArcEnvironment) -> eyre::Result + Send + Sync + 'static, - ) -> Self { - self.data_resolver = Some(Box::new(f)); - self - } - - /// Sets the gas limit for the transaction. - pub fn with_gas_limit(mut self, gas_limit: u64) -> Self { - self.gas_limit = gas_limit; - self - } - - /// Signs from the wallet at the given index (default: 0). - /// - /// Useful for tests that need to send from different roles, - /// e.g. index 7 is the operator in localdev genesis. - pub fn with_wallet_index(mut self, index: usize) -> Self { - self.wallet_index = index; - self - } - - /// Executes the action and returns the transaction hash. - pub async fn execute_and_return( - &self, - env: &mut ArcEnvironment, - ) -> eyre::Result<(TxHash, reth_primitives_traits::Recovered)> { - let (signer, chain_id) = { - let wallet = env.wallet_mut()?; - let wallets = wallet.wallet_gen(); - let signer = wallets - .get(self.wallet_index) - .ok_or_else(|| { - eyre::eyre!( - "Wallet index {} not available (only {} wallets)", - self.wallet_index, - wallets.len() - ) - })? - .clone(); - (signer, wallet.chain_id) - }; - - let nonce = env.next_nonce_for_wallet(self.wallet_index)?; - - let tx_kind = if self.create { - info!( - name = %self.name, - nonce, - value = %self.value, - "Sending CREATE transaction" - ); - TxKind::Create - } else { - // Resolve recipient: named address > explicit address > random - let to_address = if let Some(ref addr_name) = self.to_named { - *env.get_address(addr_name).ok_or_else(|| { - eyre::eyre!( - "Named address '{}' not found in environment for tx '{}'", - addr_name, - self.name - ) - })? - } else { - self.to.unwrap_or_else(Address::random) - }; - info!( - name = %self.name, - nonce, - value = %self.value, - to = %to_address, - "Sending transaction" - ); - TxKind::Call(to_address) - }; - - // Resolve calldata: deferred resolver > explicit data - let resolved_data = if let Some(ref resolver) = self.data_resolver { - Some(resolver(env)?) - } else { - self.data.clone() - }; - - // Build EIP-1559 transaction request - let tx = TransactionRequest { - nonce: Some(nonce), - value: Some(self.value), - to: Some(tx_kind), - gas: Some(self.gas_limit), - max_fee_per_gas: Some(1000e9 as u128), - max_priority_fee_per_gas: Some(1e9 as u128), - chain_id: Some(chain_id), - input: TransactionInput { - input: None, - data: resolved_data, - }, - ..Default::default() - }; - - // Sign transaction using reth's TransactionTestContext - let signed_tx = TransactionTestContext::sign_tx(signer, tx).await; - let tx_hash = *signed_tx.tx_hash(); - - debug!(tx_hash = %tx_hash, "Transaction signed"); - - // Convert TxEnvelope to TransactionSigned for pool submission - let raw_tx: Bytes = signed_tx.encoded_2718().into(); - let tx_signed = TransactionSigned::decode_2718(&mut raw_tx.as_ref()) - .map_err(|e| eyre::eyre!("Failed to decode transaction: {:?}", e))?; - - // Recover the signer - let recovered_tx = tx_signed - .try_into_recovered() - .map_err(|e| eyre::eyre!("Failed to recover transaction signer: {:?}", e))?; - - // Get pool from node and add transaction - env.node() - .inner - .pool - .add_consensus_transaction(recovered_tx.clone(), TransactionOrigin::Local) - .await - .map_err(|e| eyre::eyre!("Failed to submit transaction to pool: {:?}", e))?; - - info!(name = %self.name, tx_hash = %tx_hash, "Transaction submitted to pool"); - - Ok((tx_hash, recovered_tx)) - } -} - -impl Action for SendTransaction { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let (tx_hash, _) = self.execute_and_return(env).await?; - env.insert_tx_hash(self.name.clone(), tx_hash)?; - Ok(()) - }) - } -} diff --git a/crates/execution-e2e/src/actions/store_deployed_address.rs b/crates/execution-e2e/src/actions/store_deployed_address.rs deleted file mode 100644 index bbc742ab..00000000 --- a/crates/execution-e2e/src/actions/store_deployed_address.rs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Extracts deployed contract address from a CREATE transaction. - -use crate::{action::Action, ArcEnvironment}; -use futures_util::future::BoxFuture; -use reth_provider::TransactionsProvider; -use tracing::info; - -/// Extracts the deployed contract address from a CREATE transaction -/// and stores it in the environment under `"{tx_name}_address"`. -/// -/// Computes the address from the sender and nonce of the transaction. -pub struct StoreDeployedAddress { - tx_name: String, -} - -impl StoreDeployedAddress { - /// Creates a new action for the named CREATE transaction. - pub fn new(tx_name: impl Into) -> Self { - Self { - tx_name: tx_name.into(), - } - } -} - -impl Action for StoreDeployedAddress { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, eyre::Result<()>> { - Box::pin(async move { - let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { - eyre::eyre!("Transaction '{}' not found in environment", self.tx_name) - })?; - - let (tx, _meta) = env - .node() - .inner - .provider() - .transaction_by_hash_with_meta(tx_hash)? - .ok_or_else(|| { - eyre::eyre!("Transaction not found for '{}' ({})", self.tx_name, tx_hash) - })?; - - use reth_primitives_traits::SignerRecoverable; - let sender = tx.recover_signer().map_err(|e| { - eyre::eyre!("Failed to recover signer for '{}': {:?}", self.tx_name, e) - })?; - - use alloy_consensus::Transaction; - let nonce = tx.nonce(); - - let contract_address = sender.create(nonce); - let address_name = format!("{}_address", self.tx_name); - - info!( - tx_name = %self.tx_name, - sender = %sender, - nonce, - contract_address = %contract_address, - stored_as = %address_name, - "Stored deployed contract address" - ); - - env.insert_address(address_name, contract_address)?; - Ok(()) - }) - } -} diff --git a/crates/execution-e2e/src/environment.rs b/crates/execution-e2e/src/environment.rs deleted file mode 100644 index 8844d352..00000000 --- a/crates/execution-e2e/src/environment.rs +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Arc test environment for e2e tests. - -use alloy_primitives::{Address, BlockHash, TxHash}; -use arc_evm_node::node::ArcNode; -use reth_e2e_test_utils::{wallet::Wallet, NodeHelperType}; -use reth_node_builder::NodeTypesWithDBAdapter; -use reth_provider::providers::BlockchainProvider; -use std::collections::HashMap; - -/// Type alias for the Arc node test context. -pub type ArcNodeTestContext = NodeHelperType< - ArcNode, - BlockchainProvider>, ->; - -/// Information about a block. -#[derive(Debug, Clone)] -pub struct BlockInfo { - /// Block hash. - pub hash: BlockHash, - /// Block number. - pub number: u64, - /// Block timestamp. - pub timestamp: u64, -} - -impl BlockInfo { - /// Creates a new BlockInfo. - pub fn new(hash: BlockHash, number: u64, timestamp: u64) -> Self { - Self { - hash, - number, - timestamp, - } - } -} - -/// Arc test environment containing the node context and state. -pub struct ArcEnvironment { - /// The single node test context. - node: Option, - /// Current block information. - current_block: Option, - /// Wallet for signing transactions in tests. - wallet: Option, - /// Named transaction hashes for test assertions. - tx_hashes: HashMap, - /// Named addresses (e.g., deployed contract addresses) for test reference. - addresses: HashMap, - /// Per-wallet-index nonce counter. Index 0 is seeded from `wallet.inner_nonce` - /// during setup; other indices start at 0. - wallet_nonces: HashMap, -} - -impl Default for ArcEnvironment { - fn default() -> Self { - Self::new() - } -} - -impl ArcEnvironment { - /// Creates a new empty environment. - pub fn new() -> Self { - Self { - node: None, - current_block: None, - wallet: None, - tx_hashes: HashMap::new(), - addresses: HashMap::new(), - wallet_nonces: HashMap::new(), - } - } - - /// Sets the node context. Called by `ArcSetup::apply()`. - pub(crate) fn set_node(&mut self, node: ArcNodeTestContext) { - self.node = Some(node); - } - - /// Sets the wallet. Called by `ArcSetup::apply()`. - /// - /// Seeds the nonce counter for index 0 from the wallet's starting nonce. - pub(crate) fn set_wallet(&mut self, wallet: Wallet) { - self.wallet_nonces.insert(0, wallet.inner_nonce); - self.wallet = Some(wallet); - } - - /// Updates the current block info. - pub fn set_current_block(&mut self, block: BlockInfo) { - self.current_block = Some(block); - } - - /// Returns a reference to the node - pub fn node(&self) -> &ArcNodeTestContext { - self.node.as_ref().expect("Node not initialized.") - } - - /// Returns a mutable reference to the node - pub fn node_mut(&mut self) -> &mut ArcNodeTestContext { - self.node.as_mut().expect("Node not initialized") - } - - /// Returns the current block info - pub fn current_block(&self) -> &BlockInfo { - self.current_block - .as_ref() - .expect("No current block available.") - } - - /// Returns the current block number. - pub fn block_number(&self) -> u64 { - self.current_block().number - } - - /// Returns a mutable reference to the wallet - pub fn wallet_mut(&mut self) -> eyre::Result<&mut Wallet> { - self.wallet - .as_mut() - .ok_or_else(|| eyre::eyre!("No wallet available in environment")) - } - - /// Stores a transaction hash by name for later assertions. - pub fn insert_tx_hash(&mut self, name: String, tx_hash: TxHash) -> eyre::Result<()> { - if let Some(existing) = self.tx_hashes.get(&name) { - return Err(eyre::eyre!( - "Transaction name '{}' is already in use (existing tx_hash: {}). \ - Each transaction must have a unique name.", - name, - existing - )); - } - self.tx_hashes.insert(name, tx_hash); - Ok(()) - } - - /// Gets a transaction hash by name. - pub fn get_tx_hash(&self, name: &str) -> Option<&TxHash> { - self.tx_hashes.get(name) - } - - /// Stores a named address (e.g., deployed contract address). - pub fn insert_address(&mut self, name: String, address: Address) -> eyre::Result<()> { - if let Some(existing) = self.addresses.get(&name) { - return Err(eyre::eyre!( - "Address '{}' is already in use (existing address: {}). \ - Each address must have a unique name.", - name, - existing - )); - } - self.addresses.insert(name, address); - Ok(()) - } - - /// Gets a named address. - pub fn get_address(&self, name: &str) -> Option<&Address> { - self.addresses.get(name) - } - - /// Gets and increments the nonce for a wallet at the given index. - /// - /// All indices use the same `wallet_nonces` map. Index 0 is seeded from - /// `wallet.inner_nonce` during `set_wallet`; other indices default to 0. - pub fn next_nonce_for_wallet(&mut self, wallet_index: usize) -> eyre::Result { - let nonce = self.wallet_nonces.entry(wallet_index).or_insert(0); - let current = *nonce; - *nonce += 1; - Ok(current) - } -} diff --git a/crates/execution-e2e/src/lib.rs b/crates/execution-e2e/src/lib.rs index ae1c7355..8b7ae8df 100644 --- a/crates/execution-e2e/src/lib.rs +++ b/crates/execution-e2e/src/lib.rs @@ -14,95 +14,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -#![allow( - clippy::arithmetic_side_effects, - clippy::cast_possible_truncation, - clippy::unwrap_used -)] +#![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] //! Arc E2E Test Framework //! -//! An e2e testing framework for Arc execution, inspired by reth's testsuite architecture. -//! Uses the Action pattern for composable test scenarios. +//! New tests should use `ArcTestNode`, which exposes the live node and small +//! helpers for common Engine API and transaction operations. -mod action; -pub mod actions; pub mod chainspec; -mod environment; +mod node; mod setup; -pub use action::{Action, ActionBox}; -pub use environment::{ArcEnvironment, BlockInfo}; +pub use alloy_primitives::TxKind; +pub use node::{ArcTestNode, DEFAULT_FEE_RECIPIENT}; pub use setup::ArcSetup; - -/// Builder for creating and running Arc test scenarios. -/// -/// Follows the builder pattern to compose tests from setup and actions. -pub struct ArcTestBuilder { - setup: Option, - actions: Vec, -} - -impl Default for ArcTestBuilder { - fn default() -> Self { - Self::new() - } -} - -impl ArcTestBuilder { - /// Creates a new test builder. - pub fn new() -> Self { - Self { - setup: None, - actions: Vec::new(), - } - } - - /// Sets the test setup configuration. - pub fn with_setup(mut self, setup: ArcSetup) -> Self { - self.setup = Some(setup); - self - } - - /// Adds an action to be executed. - pub fn with_action(mut self, action: A) -> Self { - self.actions.push(ActionBox::new(action)); - self - } - - /// Runs the test scenario. - /// - /// 1. Applies the setup to create the node - /// 2. Executes all actions in sequence - pub async fn run(self) -> eyre::Result<()> { - let mut env = ArcEnvironment::new(); - - // Apply setup - if let Some(setup) = self.setup { - setup.apply(&mut env).await?; - } else { - return Err(eyre::eyre!("No setup configured")); - } - - // Execute all actions - for mut action in self.actions { - action.execute(&mut env).await?; - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_run_without_setup_returns_error() { - let result = ArcTestBuilder::new().run().await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.to_string().contains("No setup configured")); - } -} diff --git a/crates/execution-e2e/src/node.rs b/crates/execution-e2e/src/node.rs new file mode 100644 index 00000000..af97894c --- /dev/null +++ b/crates/execution-e2e/src/node.rs @@ -0,0 +1,371 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Thin execution e2e node API. + +use crate::ArcSetup; +use alloy_eips::eip7685::RequestsOrHash; +use alloy_network::eip2718::Encodable2718; +use alloy_primitives::{address, Address, Bytes, TxHash, B256, U256}; +use alloy_rpc_types_engine::{ + CancunPayloadFields, ExecutionData, ExecutionPayload, ExecutionPayloadEnvelopeV4, + ExecutionPayloadEnvelopeV5, ExecutionPayloadSidecar, ForkchoiceState, ForkchoiceUpdated, + PayloadAttributes, PayloadId, PayloadStatusEnum, PraguePayloadFields, +}; +use alloy_rpc_types_eth::{ + Block as RpcBlock, BlockId, BlockNumberOrTag, Header, Transaction, TransactionReceipt, + TransactionRequest, +}; +use alloy_rpc_types_trace::geth::{GethDebugTracingOptions, GethTrace}; +use alloy_serde::JsonStorageKey; +use alloy_signer_local::PrivateKeySigner; +use arc_evm_node::node::ArcNode; +use eyre::WrapErr; +use reth_e2e_test_utils::transaction::TransactionTestContext; +use reth_e2e_test_utils::wallet::Wallet; +use reth_e2e_test_utils::NodeHelperType; +use reth_ethereum::node::EthEngineTypes; +use reth_ethereum_primitives::TransactionSigned; +use reth_node_builder::NodeTypesWithDBAdapter; +use reth_provider::providers::BlockchainProvider; +use reth_rpc_api::{clients::EngineApiClient, DebugApiClient, EthApiClient}; + +/// JSON-RPC error code for "Unsupported Fork" per the Engine API spec. +const UNSUPPORTED_FORK_CODE: i32 = -38005; + +/// Default non-zero fee recipient used by e2e block production. +pub const DEFAULT_FEE_RECIPIENT: Address = address!("0x65E0a200006D4FF91bD59F9694220dafc49dbBC1"); + +type ArcNodeTestContext = NodeHelperType< + ArcNode, + BlockchainProvider>, +>; + +/// Live Arc execution test node. +pub struct ArcTestNode { + /// In-process Reth node test context. + pub node: ArcNodeTestContext, + /// Deterministic localdev wallet set. + wallet: Wallet, +} + +/// `EthApiClient`/`DebugApiClient` with Arc's concrete RPC types fixed, so read helpers +/// can call the client directly without repeating the turbofish generics at every site. +trait ArcEthRpc: + EthApiClient + + DebugApiClient +{ +} + +impl ArcEthRpc for T where + T: EthApiClient + + DebugApiClient +{ +} + +impl ArcTestNode { + /// Starts a new single-node test environment from setup. + pub async fn start(setup: ArcSetup) -> eyre::Result { + let (node, wallet) = setup.launch().await?; + Ok(Self { node, wallet }) + } + + /// Returns a generated localdev signer. + pub fn wallet_signer(&self, wallet_index: usize) -> eyre::Result { + let wallets = self.wallet.wallet_gen(); + let signer = wallets.get(wallet_index).ok_or_else(|| { + eyre::eyre!( + "wallet index {} not available (only {} wallets)", + wallet_index, + wallets.len() + ) + })?; + + Ok(signer.clone()) + } + + /// Prepares a transaction request with Arc e2e defaults. + pub async fn prepare_tx( + &self, + signer: &PrivateKeySigner, + mut request: TransactionRequest, + ) -> eyre::Result { + if request.nonce.is_none() { + request.nonce = Some( + self.nonce( + signer.address(), + Some(BlockId::Number(BlockNumberOrTag::Pending)), + ) + .await?, + ); + } + request.chain_id.get_or_insert(self.wallet.chain_id); + request.gas.get_or_insert(21_000); + + if request.gas_price.is_none() { + request.max_fee_per_gas.get_or_insert(1_000_000_000_000u128); + request + .max_priority_fee_per_gas + .get_or_insert(1_000_000_000u128); + } + + Ok(request) + } + + /// Prepares and signs a transaction request with the provided signer. + pub async fn sign_tx( + &self, + signer: PrivateKeySigner, + request: TransactionRequest, + ) -> eyre::Result { + let request = self.prepare_tx(&signer, request).await?; + Ok(TransactionTestContext::sign_tx(signer, request) + .await + .into()) + } + + /// Signs a transaction request with the provided signer and submits it through JSON-RPC. + pub async fn send_tx( + &self, + signer: PrivateKeySigner, + request: TransactionRequest, + ) -> eyre::Result { + let tx_signed = self.sign_tx(signer, request).await?; + self.send_signed_tx(tx_signed).await + } + + /// Sends an already signed transaction through `eth_sendRawTransaction`. + pub async fn send_signed_tx(&self, tx_signed: TransactionSigned) -> eyre::Result { + Ok(self + .rpc_client()? + .send_raw_transaction(tx_signed.encoded_2718().into()) + .await?) + } + + /// Produces one block from the current canonical head and finalizes it. + /// Mutable access serializes block production because each call advances the canonical head. + pub async fn produce_block(&mut self) -> eyre::Result<()> { + let parent = self.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = default_payload_attributes(parent.header.timestamp); + let fcu_result = self + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + assert_valid( + &fcu_result.payload_status.status, + "forkChoiceUpdated while building payload", + )?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let payload = self.get_payload(payload_id).await?; + let block_hash = payload.block_hash(); + let status = self.new_payload(payload).await?; + assert_valid(&status, "newPayload while producing block")?; + + let fcu_result = self + .fork_choice_updated(forkchoice_state(block_hash), None) + .await?; + assert_valid( + &fcu_result.payload_status.status, + "forkChoiceUpdated while finalizing block", + )?; + + Ok(()) + } + + /// Produces `count` blocks. + pub async fn produce_blocks(&mut self, count: u64) -> eyre::Result<()> { + for _ in 0..count { + self.produce_block().await?; + } + Ok(()) + } + + pub async fn fork_choice_updated( + &self, + fork_choice_state: ForkchoiceState, + payload_attributes: Option, + ) -> eyre::Result { + let engine_client = self.node.inner.auth_server_handle().http_client(); + Ok(EngineApiClient::::fork_choice_updated_v3( + &engine_client, + fork_choice_state, + payload_attributes, + ) + .await?) + } + + /// Calls `engine_getPayload` and returns the raw payload plus request sidecar. + pub async fn get_payload(&self, payload_id: PayloadId) -> eyre::Result { + let engine_client = self.node.inner.auth_server_handle().http_client(); + match EngineApiClient::::get_payload_v5(&engine_client, payload_id).await { + Ok(envelope) => Ok(execution_data_from_v5(envelope)), + Err(err) => { + if !matches!( + &err, + jsonrpsee::core::client::Error::Call(obj) + if obj.code() == UNSUPPORTED_FORK_CODE + ) { + return Err(eyre::eyre!("getPayloadV5 failed: {err}")); + } + let envelope = + EngineApiClient::::get_payload_v4(&engine_client, payload_id) + .await?; + Ok(execution_data_from_v4(envelope)) + } + } + } + + /// Calls `engine_newPayloadV4` and returns the raw Engine API status. + pub async fn new_payload(&self, payload: ExecutionData) -> eyre::Result { + let engine_client = self.node.inner.auth_server_handle().http_client(); + let ExecutionData { payload, sidecar } = payload; + let ExecutionPayload::V3(payload) = payload else { + return Err(eyre::eyre!("newPayloadV4 requires ExecutionPayloadV3")); + }; + + let versioned_hashes = sidecar.versioned_hashes().cloned().unwrap_or_default(); + let parent_beacon_block_root = sidecar.parent_beacon_block_root().unwrap_or(B256::ZERO); + let execution_requests = sidecar + .requests() + .cloned() + .ok_or_else(|| eyre::eyre!("payload sidecar is missing execution requests"))?; + let result = EngineApiClient::::new_payload_v4( + &engine_client, + payload, + versioned_hashes, + parent_beacon_block_root, + RequestsOrHash::Requests(execution_requests), + ) + .await?; + + Ok(result.status) + } + + /// Returns a block through `eth_getBlockByNumber`. + pub async fn get_block(&self, block: BlockNumberOrTag) -> eyre::Result { + self.rpc_client()? + .block_by_number(block, false) + .await? + .ok_or_else(|| eyre::eyre!("block {block:?} not found")) + } + + /// Returns account nonce through `eth_getTransactionCount`. + pub async fn nonce(&self, address: Address, block_id: Option) -> eyre::Result { + let nonce = self + .rpc_client()? + .transaction_count(address, block_id) + .await?; + u64::try_from(nonce).wrap_err("nonce does not fit in u64") + } + + /// Returns the raw JSON-RPC transaction receipt. + pub async fn get_receipt(&self, hash: TxHash) -> eyre::Result { + self.rpc_client()? + .transaction_receipt(hash) + .await? + .ok_or_else(|| eyre::eyre!("receipt for transaction {hash} not found")) + } + + /// Executes `eth_call` and returns raw output bytes. + pub async fn call(&self, request: TransactionRequest) -> eyre::Result { + Ok(self.rpc_client()?.call(request, None, None, None).await?) + } + + /// Returns account balance through `eth_getBalance`. + pub async fn balance(&self, address: Address, block_id: Option) -> eyre::Result { + Ok(self.rpc_client()?.balance(address, block_id).await?) + } + + /// Returns account storage through `eth_getStorageAt`. + pub async fn storage_at( + &self, + address: Address, + index: U256, + block_id: Option, + ) -> eyre::Result { + Ok(self + .rpc_client()? + .storage_at(address, JsonStorageKey::from(index), block_id) + .await?) + } + + /// Calls `debug_traceTransaction` with caller-provided tracing options. + pub async fn trace_transaction( + &self, + hash: TxHash, + options: GethDebugTracingOptions, + ) -> eyre::Result { + Ok(self + .rpc_client()? + .debug_trace_transaction(hash, Some(options)) + .await?) + } + + fn rpc_client(&self) -> eyre::Result { + self.node + .rpc_client() + .ok_or_else(|| eyre::eyre!("RPC client not available")) + } +} + +fn execution_data_from_v5(envelope: ExecutionPayloadEnvelopeV5) -> ExecutionData { + let sidecar = ExecutionPayloadSidecar::v4( + CancunPayloadFields::new(B256::ZERO, envelope.blobs_bundle.versioned_hashes()), + PraguePayloadFields::new(envelope.execution_requests), + ); + ExecutionData::new(ExecutionPayload::V3(envelope.execution_payload), sidecar) +} + +fn execution_data_from_v4(envelope: ExecutionPayloadEnvelopeV4) -> ExecutionData { + let sidecar = ExecutionPayloadSidecar::v4( + CancunPayloadFields::new(B256::ZERO, envelope.blobs_bundle.versioned_hashes()), + PraguePayloadFields::new(envelope.execution_requests), + ); + ExecutionData::new( + ExecutionPayload::V3(envelope.envelope_inner.execution_payload), + sidecar, + ) +} + +fn forkchoice_state(block_hash: B256) -> ForkchoiceState { + ForkchoiceState { + head_block_hash: block_hash, + safe_block_hash: block_hash, + finalized_block_hash: block_hash, + } +} + +fn default_payload_attributes(parent_timestamp: u64) -> PayloadAttributes { + PayloadAttributes { + timestamp: parent_timestamp + 1, + prev_randao: B256::random(), + suggested_fee_recipient: DEFAULT_FEE_RECIPIENT, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, + } +} + +fn assert_valid(status: &PayloadStatusEnum, context: &str) -> eyre::Result<()> { + match status { + PayloadStatusEnum::Valid => Ok(()), + other => Err(eyre::eyre!( + "{context} returned unexpected status: {other:?}" + )), + } +} diff --git a/crates/execution-e2e/src/setup.rs b/crates/execution-e2e/src/setup.rs index d3c9bd1c..fa4efd5c 100644 --- a/crates/execution-e2e/src/setup.rs +++ b/crates/execution-e2e/src/setup.rs @@ -16,18 +16,18 @@ //! Setup configuration for Arc e2e tests. -use crate::environment::{ArcEnvironment, BlockInfo}; use arc_evm_node::node::ArcNode; -use arc_execution_config::addresses_denylist::AddressesDenylistConfig; +use arc_execution_config::addresses_denylist::{ + AddressesDenylistConfig, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, DENYLIST_ADDRESS_LOCALDEV, +}; use arc_execution_config::chainspec::{ArcChainSpec, LOCAL_DEV}; use arc_execution_txpool::InvalidTxListConfig; use reth_chainspec::EthChainSpec; use reth_e2e_test_utils::NodeHelperType; -use reth_ethereum_engine_primitives::EthPayloadBuilderAttributes; +use reth_ethereum_engine_primitives::EthPayloadAttributes; use reth_node_builder::{EngineNodeLauncher, Node, NodeBuilder, NodeConfig, NodeHandle}; use reth_node_core::args::{DiscoveryArgs, NetworkArgs, RpcServerArgs}; use reth_provider::providers::BlockchainProvider; -use reth_provider::HeaderProvider; use reth_rpc_server_types::RpcModuleSelection; use reth_tasks::Runtime; use std::sync::Arc; @@ -40,6 +40,7 @@ pub struct ArcSetup { addresses_denylist_config: Option, invalid_tx_list_config: Option, rpc_gas_cap: Option, + share_sparse_trie: bool, } impl Default for ArcSetup { @@ -58,6 +59,7 @@ impl ArcSetup { addresses_denylist_config: None, invalid_tx_list_config: None, rpc_gas_cap: None, + share_sparse_trie: false, } } @@ -93,26 +95,43 @@ impl ArcSetup { self } - /// Applies the setup to create the test environment. + /// Enables Reth's shared sparse-trie payload-builder path + /// (`--engine.share-sparse-trie-with-payload-builder`) for this node. /// - /// This creates a single Arc node and initializes the environment with - /// the genesis block information. - pub async fn apply(self, env: &mut ArcEnvironment) -> eyre::Result<()> { - let mut arc_node = ArcNode::default(); - if let Some(cfg) = self.addresses_denylist_config { - arc_node.addresses_denylist_config = cfg; - } + /// Off by default: the harness disables the parallel state-root machinery so + /// concurrent CI nodes stay within the runner's memory budget (see + /// `launch_node`). Turning this on opts back into parallelism and shares the + /// engine's sparse-trie task with the payload builder, so the build path + /// reuses the precomputed root. Enable it only in tests that specifically + /// exercise that path (e.g. the sparse-trie state-root regression test). + pub fn with_share_sparse_trie(mut self, enabled: bool) -> Self { + self.share_sparse_trie = enabled; + self + } + + /// Launches the configured single-node test environment. + pub async fn launch( + self, + ) -> eyre::Result<(NodeHelperType, reth_e2e_test_utils::wallet::Wallet)> { + let denylist_config = self.addresses_denylist_config.clone().unwrap_or_else(|| { + AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + ) + }); + let mut arc_node = ArcNode::with_denylist_config(denylist_config); if let Some(cfg) = self.invalid_tx_list_config { arc_node.invalid_tx_list_cfg = cfg; } - let (node, wallet, genesis_block) = - Self::launch_node(self.chain_spec, arc_node, self.rpc_gas_cap).await?; - - env.set_node(node); - env.set_wallet(wallet); - env.set_current_block(genesis_block); - Ok(()) + Self::launch_node( + self.chain_spec, + arc_node, + self.rpc_gas_cap, + self.share_sparse_trie, + ) + .await } /// Launches a single Arc test node with the given chain spec and node configuration. @@ -120,16 +139,12 @@ impl ArcSetup { chain_spec: Arc, arc_node: ArcNode, rpc_gas_cap: Option, - ) -> eyre::Result<( - NodeHelperType, - reth_e2e_test_utils::wallet::Wallet, - BlockInfo, - )> { - let attributes_generator = |_timestamp: u64| -> EthPayloadBuilderAttributes { - EthPayloadBuilderAttributes::default() - }; + share_sparse_trie: bool, + ) -> eyre::Result<(NodeHelperType, reth_e2e_test_utils::wallet::Wallet)> { + let attributes_generator = + |_timestamp: u64| -> EthPayloadAttributes { EthPayloadAttributes::default() }; - let runtime = Runtime::with_existing_handle(tokio::runtime::Handle::current())?; + let runtime = Runtime::test(); let network_config = NetworkArgs { discovery: DiscoveryArgs { disable_discovery: true, @@ -137,8 +152,28 @@ impl ArcSetup { }, ..NetworkArgs::default() }; - let tree_config = - reth_node_api::TreeConfig::default().with_cross_block_cache_size(1024 * 1024); + // Each e2e test spawns a full in-process node, and the workspace CI shard runs + // several concurrently against a fixed 30 GiB container. reth 2.2 sizes the parallel + // proof/sparse-trie/multiproof machinery to the host's core count, so every node + // grabs as if it owns the 16-core runner — N concurrent nodes then blow the budget. + // Disable that machinery per node (sequential state root, identical results) and keep + // the cross-block cache tiny. Tests assert correctness, not build throughput. + let tree_config = if share_sparse_trie { + // Opt back into the parallel state-root machinery and share the + // engine's sparse-trie task with the payload builder, so the build + // path exercises `--engine.share-sparse-trie-with-payload-builder`. + // Used by the sparse-trie state-root regression test. Keep + // legacy_state_root disabled (the default) so `use_state_root_task()` + // is true and the shared task actually runs. + reth_node_api::TreeConfig::default() + .with_cross_block_cache_size(1024 * 1024) + .with_has_enough_parallelism(true) + .with_share_sparse_trie_with_payload_builder(true) + } else { + reth_node_api::TreeConfig::default() + .with_cross_block_cache_size(1024 * 1024) + .with_has_enough_parallelism(false) + }; let mut rpc_args = RpcServerArgs::default() .with_unused_ports() .with_http() @@ -179,18 +214,11 @@ impl ArcSetup { let genesis = node.block_hash(genesis_number); node.update_forkchoice(genesis, genesis).await?; - let genesis_header = node - .inner - .provider() - .header_by_number(genesis_number)? - .ok_or_else(|| eyre::eyre!("Genesis header not found"))?; - let genesis_block = BlockInfo::new(genesis, 0, genesis_header.timestamp); - // Generate 10 wallets to match localdev genesis allocations. // Index 7 is the operator (minter role on NativeFiatToken). let wallet = reth_e2e_test_utils::wallet::Wallet::new(10).with_chain_id(chain_spec.chain().id()); - Ok((node, wallet, genesis_block)) + Ok((node, wallet)) } } diff --git a/crates/execution-e2e/tests/block_hash_history.rs b/crates/execution-e2e/tests/block_hash_history.rs deleted file mode 100644 index 2025b18d..00000000 --- a/crates/execution-e2e/tests/block_hash_history.rs +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! EIP-2935 BlockHashHistory e2e tests for Arc Chain. -//! -//! Tests that the EIP-2935 system call persists parent block hashes in the -//! history storage contract at `0x0000F90827F1C53a10cb7A02335B175320002935`. -//! -//! The system call runs at the start of each block (Zero5+), storing -//! `parent_hash` in a ring buffer of size 8191. - -use alloy_primitives::{address, Address, Bytes}; -use arc_execution_config::hardforks::ArcHardfork; -use arc_execution_e2e::{ - actions::{AssertBlockNumber, CallContract, ProduceBlocks}, - chainspec::localdev_with_hardforks, - ArcSetup, ArcTestBuilder, -}; -use eyre::Result; -use reth_chainspec::ForkCondition; - -/// EIP-2935 History Storage Contract address. -const HISTORY_STORAGE_ADDRESS: Address = address!("0000F90827F1C53a10cb7A02335B175320002935"); - -/// Helper: encode a block number as 32-byte big-endian calldata for the -/// history storage contract's `get(uint256)` interface. -fn block_number_calldata(block_number: u64) -> Bytes { - let mut buf = [0u8; 32]; - buf[24..32].copy_from_slice(&block_number.to_be_bytes()); - Bytes::copy_from_slice(&buf) -} - -/// After producing blocks, querying the history storage contract for a recent -/// block number should return a non-zero hash (the parent hash written by the -/// EIP-2935 system call). -#[tokio::test] -async fn test_block_hash_history_returns_non_zero_for_recent_block() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Produce 3 blocks so block 1 and 2 have parent hashes stored. - .with_action(ProduceBlocks::new(3)) - // Query block 1: the system call at block 1 stores the genesis hash. - .with_action( - CallContract::new("block_hash_history_block_1") - .to(HISTORY_STORAGE_ADDRESS) - .with_data(block_number_calldata(1)) - .expect_non_zero_result(), - ) - // Query block 2: the system call at block 2 stores block 1's hash. - .with_action( - CallContract::new("block_hash_history_block_2") - .to(HISTORY_STORAGE_ADDRESS) - .with_data(block_number_calldata(2)) - .expect_non_zero_result(), - ) - .run() - .await -} - -/// Querying the history storage contract for a far-future block number should -/// return zero (no hash stored). -#[tokio::test] -async fn test_block_hash_history_returns_zero_for_future_block() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action(ProduceBlocks::new(1)) - // Query a block number far in the future — should return zero. - .with_action( - CallContract::new("block_hash_history_future_block") - .to(HISTORY_STORAGE_ADDRESS) - .with_data(block_number_calldata(99999)) - .expect_revert(), - ) - .run() - .await -} - -/// Crossing the Zero5 activation boundary: hashes must start being written at the -/// activation block and not before. -/// -/// Chain spec: Zero5 activates at block 3. -/// - Produce blocks 1-2: pre-Zero5, no entries written. -/// - Produce blocks 3-4: Zero5 active, system call runs. -/// - Assert: slot for block 1 is zero (pre-activation, no system call wrote there). -/// - Assert: contract has an entry for block 3 (first Zero5 block). -#[tokio::test] -async fn test_block_hash_history_starts_at_zero5_activation() -> Result<()> { - reth_tracing::init_test_tracing(); - - let chain_spec = localdev_with_hardforks(&[ - (ArcHardfork::Zero3, ForkCondition::Block(0)), - (ArcHardfork::Zero4, ForkCondition::Block(0)), - (ArcHardfork::Zero5, ForkCondition::Block(3)), - (ArcHardfork::Zero6, ForkCondition::Block(3)), - ]); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new().with_chain_spec(chain_spec)) - .with_action(ProduceBlocks::new(4)) - .with_action(AssertBlockNumber::new(4)) - // Block 1 is pre-Zero5 — no system call wrote to slot 1, expect zero. - .with_action( - CallContract::new("block_hash_history_pre_activation") - .to(HISTORY_STORAGE_ADDRESS) - .with_data(block_number_calldata(1)) - .expect_result(Bytes::from([0u8; 32])), - ) - // Block 3 is the first Zero5 block — system call ran, expect non-zero. - .with_action( - CallContract::new("block_hash_history_at_activation") - .to(HISTORY_STORAGE_ADDRESS) - .with_data(block_number_calldata(3)) - .expect_non_zero_result(), - ) - .run() - .await -} diff --git a/crates/execution-e2e/tests/block_production.rs b/crates/execution-e2e/tests/block_production.rs deleted file mode 100644 index 17f2847c..00000000 --- a/crates/execution-e2e/tests/block_production.rs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Basic block production e2e tests for Arc Chain. - -use arc_execution_e2e::{ - actions::{AssertBlockNumber, ProduceBlocks, ProduceInvalidBlock}, - ArcSetup, ArcTestBuilder, -}; -use eyre::Result; - -/// Test produce a single block. -#[tokio::test] -async fn test_produce_single_block() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertBlockNumber::new(1)) - .run() - .await -} - -/// Test produce multiple blocks. -#[tokio::test] -async fn test_incremental_block_production() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action(ProduceBlocks::new(3)) - .with_action(AssertBlockNumber::new(3)) - .with_action(ProduceBlocks::new(2)) - .with_action(AssertBlockNumber::new(5)) - .run() - .await -} - -/// Test that blocks with corrupted state root are rejected. -#[tokio::test] -async fn test_block_with_corrupted_state_root_rejected() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Produce a valid block first - .with_action(ProduceBlocks::new(1)) - .with_action(AssertBlockNumber::new(1)) - // Try to produce a block with corrupted state root - should be rejected - .with_action(ProduceInvalidBlock::new()) - .with_action(AssertBlockNumber::new(1)) - .run() - .await -} diff --git a/crates/execution-e2e/tests/denylist.rs b/crates/execution-e2e/tests/denylist.rs deleted file mode 100644 index 56b8dc00..00000000 --- a/crates/execution-e2e/tests/denylist.rs +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] - -//! E2E tests for the addresses denylist. -//! -//! Covers: -//! - to: denylisted → rejected -//! - from: denylisted → rejected -//! - denylist disabled: from denylisted → accepted -//! - addresses-exclusions: from denylisted but excluded → accepted - -use alloy_network::eip2718::{Decodable2718, Encodable2718}; -use alloy_primitives::{address, Address, TxKind, U256}; -use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; -use arc_execution_config::addresses_denylist::{ - AddressesDenylistConfig, DEFAULT_DENYLIST_ADDRESS, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, -}; -use arc_execution_config::chainspec::ArcChainSpec; -use arc_execution_e2e::{chainspec::localdev_with_denylisted_addresses, ArcEnvironment, ArcSetup}; -use arc_execution_txpool::ArcTransactionValidatorError; -use eyre::Result; -use reth_chainspec::EthChainSpec; -use reth_e2e_test_utils::transaction::TransactionTestContext; -use reth_ethereum_primitives::TransactionSigned; -use reth_primitives_traits::SignerRecoverable; -use reth_transaction_pool::error::{PoolError, PoolErrorKind}; -use reth_transaction_pool::{TransactionOrigin, TransactionPool}; -use std::sync::Arc; - -/// First account from test mnemonic (0xf39Fd...), funded in localdev genesis. -const WALLET_FIRST_ADDRESS: Address = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); - -fn assert_denylisted_address_error(err: &PoolError, expected_addr: Address) { - let invalid = match &err.kind { - PoolErrorKind::InvalidTransaction(e) => e, - other => panic!("Expected InvalidTransaction (denylist), got: {:?}", other), - }; - let arc_err = invalid - .downcast_other_ref::() - .expect("Expected ArcTransactionValidatorError"); - match arc_err { - ArcTransactionValidatorError::DenylistedAddressError(addr) => { - assert_eq!(addr, &expected_addr); - } - other => panic!("Expected DenylistedAddressError, got: {:?}", other), - } -} - -fn denylist_config_enabled(exclusions: Vec
) -> Result { - Ok(AddressesDenylistConfig::try_new( - true, - Some(DEFAULT_DENYLIST_ADDRESS), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), - exclusions, - )?) -} - -fn denylist_config_disabled() -> Result { - Ok(AddressesDenylistConfig::try_new( - false, - None, - None, - Vec::new(), - )?) -} - -/// Builds a signed tx from the first test wallet (WALLET_FIRST_ADDRESS) to `to`, returns raw encoded bytes. -async fn build_signed_tx_raw(chain_spec: &ArcChainSpec, to: Address) -> alloy_primitives::Bytes { - let wallet = - reth_e2e_test_utils::wallet::Wallet::default().with_chain_id(chain_spec.chain().id()); - let signer = wallet - .wallet_gen() - .first() - .cloned() - .expect("First wallet from test mnemonic"); - let tx = TransactionRequest { - nonce: Some(0), - value: Some(U256::from(1)), - to: Some(TxKind::Call(to)), - gas: Some(26000), - max_fee_per_gas: Some(1000e9 as u128), - max_priority_fee_per_gas: Some(1e9 as u128), - chain_id: Some(wallet.chain_id), - input: TransactionInput::default(), - ..Default::default() - }; - let signed_tx = TransactionTestContext::sign_tx(signer, tx).await; - signed_tx.encoded_2718().into() -} - -/// Launches a node with the given chain spec and denylist config, then signs and submits -/// a tx from the first wallet to `to`. Returns pool result (Err is PoolError when pool rejects). -async fn sign_and_submit_tx( - chain_spec: Arc, - addresses_denylist_config: AddressesDenylistConfig, - to: Address, -) -> Result<(), eyre::Report> { - let mut env = ArcEnvironment::new(); - ArcSetup::new() - .with_chain_spec(chain_spec.clone()) - .with_addresses_denylist_config(addresses_denylist_config) - .apply(&mut env) - .await?; - - let raw_tx = build_signed_tx_raw(&chain_spec, to).await; - let tx_signed = TransactionSigned::decode_2718(&mut raw_tx.as_ref()).expect("Decode tx"); - let recovered_tx = tx_signed.try_into_recovered().expect("Recover signer"); - env.node() - .inner - .pool - .add_consensus_transaction(recovered_tx, TransactionOrigin::Local) - .await - .map_err(Into::into) - .map(|_| ()) -} - -/// Transaction to a denylisted address is rejected. -#[tokio::test] -async fn test_denylisted_to_rejected() -> Result<()> { - reth_tracing::init_test_tracing(); - let denylisted_to = address!("0xdead000000000000000000000000000000000001"); - let chain_spec = localdev_with_denylisted_addresses(vec![denylisted_to]); - let addresses_denylist_config = denylist_config_enabled(Vec::new())?; - let err = sign_and_submit_tx(chain_spec, addresses_denylist_config, denylisted_to) - .await - .expect_err("Expected pool to reject tx to denylisted address"); - let pool_err = err.downcast_ref::().expect("Expected PoolError"); - assert_denylisted_address_error(pool_err, denylisted_to); - Ok(()) -} - -/// Transaction from a denylisted address is rejected. -#[tokio::test] -async fn test_denylisted_from_rejected() -> Result<()> { - reth_tracing::init_test_tracing(); - let chain_spec = localdev_with_denylisted_addresses(vec![WALLET_FIRST_ADDRESS]); - let addresses_denylist_config = denylist_config_enabled(Vec::new())?; - let err = sign_and_submit_tx(chain_spec, addresses_denylist_config, Address::random()) - .await - .expect_err("Expected pool to reject tx from denylisted address"); - let pool_err = err.downcast_ref::().expect("Expected PoolError"); - assert_denylisted_address_error(pool_err, WALLET_FIRST_ADDRESS); - Ok(()) -} - -/// When denylist is disabled (--arc.denylist.enabled=false), tx from denylisted address is accepted. -#[tokio::test] -async fn test_denylist_disabled_accepts_from_denylisted() -> Result<()> { - reth_tracing::init_test_tracing(); - let chain_spec = localdev_with_denylisted_addresses(vec![WALLET_FIRST_ADDRESS]); - let addresses_denylist_config = denylist_config_disabled()?; - sign_and_submit_tx(chain_spec, addresses_denylist_config, Address::random()) - .await - .expect("Expected pool to accept tx when denylist disabled"); - Ok(()) -} - -/// When denylist is enabled --arc.denylist.enabled=true, but address is in --arc.denylist.addresses-exclusions, tx from that denylisted address is accepted. -#[tokio::test] -async fn test_denylist_exclusion_accepts_from_denylisted() -> Result<()> { - reth_tracing::init_test_tracing(); - let chain_spec = localdev_with_denylisted_addresses(vec![WALLET_FIRST_ADDRESS]); - let addresses_denylist_config = denylist_config_enabled(vec![WALLET_FIRST_ADDRESS])?; - sign_and_submit_tx(chain_spec, addresses_denylist_config, Address::random()) - .await - .expect("Expected pool to accept tx when sender in addresses-exclusions"); - Ok(()) -} diff --git a/crates/execution-e2e/tests/base_fee.rs b/crates/execution-e2e/tests/e2e/base_fee.rs similarity index 62% rename from crates/execution-e2e/tests/base_fee.rs rename to crates/execution-e2e/tests/e2e/base_fee.rs index aee49b51..eda84258 100644 --- a/crates/execution-e2e/tests/base_fee.rs +++ b/crates/execution-e2e/tests/e2e/base_fee.rs @@ -18,18 +18,14 @@ use alloy_primitives::{Bytes, U256}; use alloy_rpc_types_engine::PayloadStatusEnum; +use alloy_rpc_types_eth::BlockNumberOrTag; use arc_execution_config::{gas_fee::decode_base_fee_from_bytes, hardforks::ArcHardfork}; -use arc_execution_e2e::{ - actions::{ - build_payload_for_next_block, set_payload_override_and_rehash, submit_payload, - ProduceBlocks, - }, - chainspec::localdev_with_hardforks, - Action, ArcEnvironment, ArcSetup, -}; +use arc_execution_e2e::{chainspec::localdev_with_hardforks, ArcSetup, ArcTestNode}; use eyre::Result; use reth_chainspec::ForkCondition; +use super::helpers::payload::{forkchoice_state, mutate_payload, next_payload_attributes}; + // ADR-0004 encodes the next block's required base fee in parent's `extra_data` (8 bytes). // Two independent checks enforce this on every new block: // @@ -80,35 +76,35 @@ async fn test_parent_child_base_fee_continuity_rejected() -> Result<()> { async fn test_incorrect_extra_data_base_fee_rejected_as_invalid_payload() -> Result<()> { reth_tracing::init_test_tracing(); - let mut env = ArcEnvironment::new(); - ArcSetup::new().apply(&mut env).await?; + let mut node = ArcTestNode::start(ArcSetup::new()).await?; // Produce block 1 so block 2 has a valid parent. - let mut produce = ProduceBlocks::new(1); - produce.execute(&mut env).await?; + node.produce_block().await?; // Build block 2 payload and then corrupt extra_data with a wrong 8-byte base fee value. - let (mut payload, execution_requests, parent_beacon_block_root) = - build_payload_for_next_block(&env).await?; - - let correct_extra_data = &payload.payload_inner.payload_inner.extra_data; + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let mut payload = node.get_payload(payload_id).await?; + + let correct_extra_data = &payload.payload.as_v1().extra_data; let correct_base_fee = decode_base_fee_from_bytes(correct_extra_data) .ok_or_else(|| eyre::eyre!("block 2 extra_data does not contain a valid base fee"))?; let wrong_base_fee = correct_base_fee.wrapping_add(1); let wrong_extra_data: Bytes = wrong_base_fee.to_be_bytes().to_vec().into(); - let mut payload_override = payload.payload_inner.payload_inner.clone(); - payload_override.extra_data = wrong_extra_data; - set_payload_override_and_rehash( - &mut payload, - &execution_requests, - parent_beacon_block_root, - payload_override, - )?; + mutate_payload(&mut payload, |payload| { + payload.as_v1_mut().extra_data = wrong_extra_data; + })?; - let status = - submit_payload(&env, payload, execution_requests, parent_beacon_block_root).await?; + let status = node.new_payload(payload).await?; assert!( matches!( @@ -122,17 +118,16 @@ async fn test_incorrect_extra_data_base_fee_rejected_as_invalid_payload() -> Res Ok(()) } -/// arc_validate_header_base_fee enforces absolute bounds on base_fee_per_gas under Zero5. +/// arc_validate_header_base_fee enforces absolute bounds on base_fee_per_gas. /// -/// - Zero5 active (LOCAL_DEV default): base_fee_per_gas = 0 is below absolute_min = 1 -/// → INVALID with "block base fee mismatch" (ConsensusError::BaseFeeDiff). -/// - Zero5 not yet active: same override passes the bounds check (the block may still -/// fail for other reasons such as state root mismatch, but NOT with the bounds error). +/// Even when chain metadata schedules Zero5 later, base_fee_per_gas = 0 is +/// below absolute_min = 1 and must be INVALID with "block base fee mismatch" +/// (ConsensusError::BaseFeeDiff). #[tokio::test] -async fn test_base_fee_absolute_bounds_enforced_only_after_zero5() -> Result<()> { +async fn test_base_fee_absolute_bounds_enforced_before_zero5_activation() -> Result<()> { reth_tracing::init_test_tracing(); - // Zero5 active: base_fee_per_gas=0 must be rejected with the bounds error. + // Default localdev: base_fee_per_gas=0 must be rejected with the bounds error. let status = submit_with_base_fee(ArcSetup::new(), U256::ZERO).await?; assert!( matches!( @@ -140,24 +135,27 @@ async fn test_base_fee_absolute_bounds_enforced_only_after_zero5() -> Result<()> PayloadStatusEnum::Invalid { validation_error } if validation_error.contains("block base fee mismatch") ), - "Zero5 active: expected INVALID with 'block base fee mismatch', got {status:?}" + "default localdev: expected INVALID with 'block base fee mismatch', got {status:?}" ); - // Zero5 not yet active: the bounds check is skipped — "block base fee mismatch" must not appear. - let pre_zero5_spec = localdev_with_hardforks(&[ + // Delayed Zero5 metadata: the baseline still enforces the bounds error. + let delayed_zero5_spec = localdev_with_hardforks(&[ (ArcHardfork::Zero3, ForkCondition::Block(0)), (ArcHardfork::Zero4, ForkCondition::Block(0)), (ArcHardfork::Zero5, ForkCondition::Block(10)), ]); - let status = - submit_with_base_fee(ArcSetup::new().with_chain_spec(pre_zero5_spec), U256::ZERO).await?; + let status = submit_with_base_fee( + ArcSetup::new().with_chain_spec(delayed_zero5_spec), + U256::ZERO, + ) + .await?; assert!( - !matches!( + matches!( &status, PayloadStatusEnum::Invalid { validation_error } if validation_error.contains("block base fee mismatch") ), - "Zero5 inactive: expected bounds check to be skipped, got {status:?}" + "delayed Zero5 metadata: expected INVALID with 'block base fee mismatch', got {status:?}" ); Ok(()) @@ -169,45 +167,49 @@ async fn test_base_fee_absolute_bounds_enforced_only_after_zero5() -> Result<()> /// arc_validate_against_parent_base_fee skips genesis parents (block 0), so block 1 /// must exist before the continuity check can fire. async fn submit_with_wrong_parent_base_fee(setup: ArcSetup) -> Result { - let mut env = ArcEnvironment::new(); - setup.apply(&mut env).await?; + let mut node = ArcTestNode::start(setup).await?; // Produce block 1 — this gives block 2 a non-genesis parent with valid extra_data. - ProduceBlocks::new(1).execute(&mut env).await?; - - let (mut payload, execution_requests, parent_beacon_block_root) = - build_payload_for_next_block(&env).await?; + node.produce_block().await?; + + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let mut payload = node.get_payload(payload_id).await?; // The builder sets base_fee_per_gas to match parent's nextBaseFee. Adding 1 breaks continuity. - let correct = payload.payload_inner.payload_inner.base_fee_per_gas; - let mut payload_override = payload.payload_inner.payload_inner.clone(); - payload_override.base_fee_per_gas = correct + U256::from(1u64); - set_payload_override_and_rehash( - &mut payload, - &execution_requests, - parent_beacon_block_root, - payload_override, - )?; - - submit_payload(&env, payload, execution_requests, parent_beacon_block_root).await + let correct = payload.payload.as_v1().base_fee_per_gas; + mutate_payload(&mut payload, |payload| { + payload.as_v1_mut().base_fee_per_gas = correct + U256::from(1u64); + })?; + + node.new_payload(payload).await } /// Builds a block with `base_fee_per_gas` overridden to the given value and submits it. async fn submit_with_base_fee(setup: ArcSetup, base_fee: U256) -> Result { - let mut env = ArcEnvironment::new(); - setup.apply(&mut env).await?; - - let (mut payload, execution_requests, parent_beacon_block_root) = - build_payload_for_next_block(&env).await?; - - let mut payload_override = payload.payload_inner.payload_inner.clone(); - payload_override.base_fee_per_gas = base_fee; - set_payload_override_and_rehash( - &mut payload, - &execution_requests, - parent_beacon_block_root, - payload_override, - )?; - - submit_payload(&env, payload, execution_requests, parent_beacon_block_root).await + let node = ArcTestNode::start(setup).await?; + + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let mut payload = node.get_payload(payload_id).await?; + + mutate_payload(&mut payload, |payload| { + payload.as_v1_mut().base_fee_per_gas = base_fee; + })?; + + node.new_payload(payload).await } diff --git a/crates/execution-e2e/tests/beneficiary_blocklist.rs b/crates/execution-e2e/tests/e2e/beneficiary_blocklist.rs similarity index 61% rename from crates/execution-e2e/tests/beneficiary_blocklist.rs rename to crates/execution-e2e/tests/e2e/beneficiary_blocklist.rs index b9c2c09a..5db52d5f 100644 --- a/crates/execution-e2e/tests/beneficiary_blocklist.rs +++ b/crates/execution-e2e/tests/e2e/beneficiary_blocklist.rs @@ -18,13 +18,12 @@ use alloy_primitives::address; use alloy_rpc_types_engine::PayloadStatusEnum; -use arc_execution_e2e::{ - actions::{build_payload_for_next_block, set_payload_override_and_rehash, submit_payload}, - chainspec::localdev_with_storage_override, - ArcEnvironment, ArcSetup, -}; +use alloy_rpc_types_eth::BlockNumberOrTag; +use arc_execution_e2e::{chainspec::localdev_with_storage_override, ArcSetup, ArcTestNode}; use eyre::Result; +use super::helpers::payload::{forkchoice_state, mutate_payload, next_payload_attributes}; + /// Ensure proposer-selected beneficiaries are rejected when blocklisted. /// /// - Header beneficiary is pre-blocklisted in NativeCoinControl @@ -36,26 +35,26 @@ async fn test_proposer_selected_blocklisted_beneficiary_is_invalid() -> Result<( let blocklisted_beneficiary = address!("0xbad0000000000000000000000000000000000001"); let chain_spec = localdev_with_storage_override(Some(blocklisted_beneficiary)); - let mut env = ArcEnvironment::new(); - ArcSetup::new() - .with_chain_spec(chain_spec) - .apply(&mut env) - .await?; + let node = ArcTestNode::start(ArcSetup::new().with_chain_spec(chain_spec)).await?; - let (mut payload, execution_requests, parent_beacon_block_root) = - build_payload_for_next_block(&env).await?; - let mut payload_override = payload.payload_inner.payload_inner.clone(); - payload_override.fee_recipient = blocklisted_beneficiary; - set_payload_override_and_rehash( - &mut payload, - &execution_requests, - parent_beacon_block_root, - payload_override, - )?; + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let mut payload = node.get_payload(payload_id).await?; + mutate_payload(&mut payload, |payload| { + payload.as_v1_mut().fee_recipient = blocklisted_beneficiary; + })?; - let status = submit_payload(&env, payload, execution_requests, parent_beacon_block_root) + let status = node + .new_payload(payload) .await - .expect("submit_payload should return Ok for blocklisted proposer-selected beneficiary"); + .expect("new_payload should return Ok for blocklisted proposer-selected beneficiary"); assert!( matches!( diff --git a/crates/execution-e2e/tests/e2e/block_hash_history.rs b/crates/execution-e2e/tests/e2e/block_hash_history.rs new file mode 100644 index 00000000..454cede5 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/block_hash_history.rs @@ -0,0 +1,157 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-2935 BlockHashHistory e2e tests for Arc Chain. +//! +//! Tests that the EIP-2935 system call persists parent block hashes in the +//! history storage contract at `0x0000F90827F1C53a10cb7A02335B175320002935`. +//! +//! The system call runs at the start of each block, storing `parent_hash` in a +//! ring buffer of size 8191. + +use alloy_primitives::{address, Address, Bytes}; +use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionInput, TransactionRequest}; +use arc_execution_config::hardforks::ArcHardfork; +use arc_execution_e2e::{chainspec::localdev_with_hardforks, ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; +use reth_chainspec::ForkCondition; + +/// EIP-2935 History Storage Contract address. +const HISTORY_STORAGE_ADDRESS: Address = address!("0000F90827F1C53a10cb7A02335B175320002935"); + +/// Helper: encode a block number as 32-byte big-endian calldata for the +/// history storage contract's `get(uint256)` interface. +fn block_number_calldata(block_number: u64) -> Bytes { + let mut buf = [0u8; 32]; + buf[24..32].copy_from_slice(&block_number.to_be_bytes()); + Bytes::copy_from_slice(&buf) +} + +/// After producing blocks, querying the history storage contract for a recent +/// block number should return that block's canonical hash. +#[tokio::test] +async fn test_block_hash_history_returns_canonical_hash_for_recent_block() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + // Produce block 3 so block 1 and block 2 hashes have been stored by their child blocks. + node.produce_blocks(3).await?; + + let block_1_hash = node + .get_block(BlockNumberOrTag::Number(1)) + .await? + .header + .hash; + let history_block_1 = node + .call(TransactionRequest { + to: Some(TxKind::Call(HISTORY_STORAGE_ADDRESS)), + input: TransactionInput::new(block_number_calldata(1)), + ..Default::default() + }) + .await?; + assert_eq!(history_block_1.as_ref(), block_1_hash.as_slice()); + + let block_2_hash = node + .get_block(BlockNumberOrTag::Number(2)) + .await? + .header + .hash; + let history_block_2 = node + .call(TransactionRequest { + to: Some(TxKind::Call(HISTORY_STORAGE_ADDRESS)), + input: TransactionInput::new(block_number_calldata(2)), + ..Default::default() + }) + .await?; + assert_eq!(history_block_2.as_ref(), block_2_hash.as_slice()); + Ok(()) +} + +/// Querying the history storage contract for a far-future block number should revert. +#[tokio::test] +async fn test_block_hash_history_reverts_for_future_block() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + node.produce_block().await?; + + let result = node + .call(TransactionRequest { + to: Some(TxKind::Call(HISTORY_STORAGE_ADDRESS)), + input: TransactionInput::new(block_number_calldata(99999)), + ..Default::default() + }) + .await; + assert!(result.is_err()); + Ok(()) +} + +/// Block-hash history writes use the baseline behavior even before Zero5 +/// metadata activation. +/// +/// Chain spec: Zero5 activates at block 3. +/// Produce blocks 1-4, then assert the contract returns exact canonical hashes +/// from before activation and at activation. +#[tokio::test] +async fn test_block_hash_history_writes_before_zero5_metadata_activation() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = localdev_with_hardforks(&[ + (ArcHardfork::Zero3, ForkCondition::Block(0)), + (ArcHardfork::Zero4, ForkCondition::Block(0)), + (ArcHardfork::Zero5, ForkCondition::Block(3)), + (ArcHardfork::Zero6, ForkCondition::Block(3)), + ]); + + let mut node = ArcTestNode::start(ArcSetup::new().with_chain_spec(chain_spec)).await?; + node.produce_blocks(4).await?; + assert_eq!( + node.get_block(BlockNumberOrTag::Latest) + .await? + .header + .number, + 4 + ); + + let block_1_hash = node + .get_block(BlockNumberOrTag::Number(1)) + .await? + .header + .hash; + let before_activation = node + .call(TransactionRequest { + to: Some(TxKind::Call(HISTORY_STORAGE_ADDRESS)), + input: TransactionInput::new(block_number_calldata(1)), + ..Default::default() + }) + .await?; + assert_eq!(before_activation.as_ref(), block_1_hash.as_slice()); + + let block_3_hash = node + .get_block(BlockNumberOrTag::Number(3)) + .await? + .header + .hash; + let at_activation = node + .call(TransactionRequest { + to: Some(TxKind::Call(HISTORY_STORAGE_ADDRESS)), + input: TransactionInput::new(block_number_calldata(3)), + ..Default::default() + }) + .await?; + assert_eq!(at_activation.as_ref(), block_3_hash.as_slice()); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/block_production.rs b/crates/execution-e2e/tests/e2e/block_production.rs new file mode 100644 index 00000000..e5c843f8 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/block_production.rs @@ -0,0 +1,107 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Basic block production e2e tests for Arc Chain. + +use alloy_primitives::B256; +use alloy_rpc_types_engine::PayloadStatusEnum; +use alloy_rpc_types_eth::BlockNumberOrTag; +use arc_execution_e2e::{ArcSetup, ArcTestNode}; +use eyre::Result; + +use super::helpers::payload::{forkchoice_state, mutate_payload, next_payload_attributes}; + +/// Test produce a single block. +#[tokio::test] +async fn test_produce_single_block() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + node.produce_block().await?; + assert_eq!( + node.get_block(BlockNumberOrTag::Latest) + .await? + .header + .number, + 1 + ); + Ok(()) +} + +/// Test produce multiple blocks. +#[tokio::test] +async fn test_incremental_block_production() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + node.produce_blocks(3).await?; + assert_eq!( + node.get_block(BlockNumberOrTag::Latest) + .await? + .header + .number, + 3 + ); + node.produce_blocks(2).await?; + assert_eq!( + node.get_block(BlockNumberOrTag::Latest) + .await? + .header + .number, + 5 + ); + Ok(()) +} + +/// Test that blocks with corrupted state root are rejected. +#[tokio::test] +async fn test_block_with_corrupted_state_root_rejected() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + node.produce_block().await?; + assert_eq!( + node.get_block(BlockNumberOrTag::Latest) + .await? + .header + .number, + 1 + ); + + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let mut payload = node.get_payload(payload_id).await?; + mutate_payload(&mut payload, |payload| { + payload.as_v1_mut().state_root = B256::random(); + })?; + let status = node.new_payload(payload).await?; + assert!(matches!(status, PayloadStatusEnum::Invalid { .. })); + assert_eq!( + node.get_block(BlockNumberOrTag::Latest) + .await? + .header + .number, + 1 + ); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/denylist.rs b/crates/execution-e2e/tests/e2e/denylist.rs new file mode 100644 index 00000000..63a120e5 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/denylist.rs @@ -0,0 +1,133 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] + +//! E2E tests for the addresses denylist. +//! +//! Covers: +//! - to: denylisted → rejected +//! - from: denylisted → rejected +//! - addresses-exclusions: from denylisted but excluded → accepted + +use alloy_primitives::{address, Address, TxKind, U256}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; +use arc_execution_config::addresses_denylist::{ + AddressesDenylistConfig, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, DENYLIST_ADDRESS_LOCALDEV, +}; +use arc_execution_config::chainspec::ArcChainSpec; +use arc_execution_e2e::{chainspec::localdev_with_denylisted_addresses, ArcSetup, ArcTestNode}; +use eyre::Result; +use jsonrpsee::core::client::Error as RpcClientError; +use reth_e2e_test_utils::wallet::Wallet; +use std::sync::Arc; + +fn assert_denylisted_address_error(err: &eyre::Report, expected_addr: Address) { + let rpc_err = err + .downcast_ref::() + .expect("Expected JSON-RPC client error"); + let RpcClientError::Call(call_err) = rpc_err else { + panic!("Expected JSON-RPC call error, got: {rpc_err:?}"); + }; + let message = call_err.message().to_lowercase(); + assert!( + message.contains("is denylisted"), + "Expected denylist rejection in RPC error, got: {call_err:?}" + ); + assert!( + message.contains(&expected_addr.to_string().to_lowercase()), + "Expected denylisted address {expected_addr} in RPC error, got: {call_err:?}" + ); +} + +fn denylist_config_enabled(exclusions: Vec
) -> AddressesDenylistConfig { + AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + exclusions, + ) +} + +/// Launches a node with the given chain spec and denylist config, then signs and submits +/// a tx from the first wallet to `to`. Returns pool result (Err is PoolError when pool rejects). +async fn sign_and_submit_tx( + chain_spec: Arc, + addresses_denylist_config: AddressesDenylistConfig, + to: Address, +) -> Result<(), eyre::Report> { + let node = ArcTestNode::start( + ArcSetup::new() + .with_chain_spec(chain_spec.clone()) + .with_addresses_denylist_config(addresses_denylist_config), + ) + .await?; + let signer = node.wallet_signer(0)?; + + node.send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + value: Some(U256::from(1)), + to: Some(TxKind::Call(to)), + gas: Some(26_000), + input: TransactionInput::default(), + ..Default::default() + }, + ) + .await + .map(|_| ()) +} + +/// Transaction to a denylisted address is rejected. +#[tokio::test] +async fn test_denylisted_to_rejected() -> Result<()> { + reth_tracing::init_test_tracing(); + let denylisted_to = address!("0xdead000000000000000000000000000000000001"); + let chain_spec = localdev_with_denylisted_addresses(vec![denylisted_to]); + let addresses_denylist_config = denylist_config_enabled(Vec::new()); + let err = sign_and_submit_tx(chain_spec, addresses_denylist_config, denylisted_to) + .await + .expect_err("Expected RPC submission to reject tx to denylisted address"); + assert_denylisted_address_error(&err, denylisted_to); + Ok(()) +} + +/// Transaction from a denylisted address is rejected. +#[tokio::test] +async fn test_denylisted_from_rejected() -> Result<()> { + reth_tracing::init_test_tracing(); + let sender = Wallet::new(1).wallet_gen()[0].address(); + let chain_spec = localdev_with_denylisted_addresses(vec![sender]); + let addresses_denylist_config = denylist_config_enabled(Vec::new()); + let err = sign_and_submit_tx(chain_spec, addresses_denylist_config, Address::random()) + .await + .expect_err("Expected RPC submission to reject tx from denylisted address"); + assert_denylisted_address_error(&err, sender); + Ok(()) +} + +/// An address in `--arc.denylist.addresses-exclusions` is accepted despite being denylisted. +#[tokio::test] +async fn test_denylist_exclusion_accepts_from_denylisted() -> Result<()> { + reth_tracing::init_test_tracing(); + let sender = Wallet::new(1).wallet_gen()[0].address(); + let chain_spec = localdev_with_denylisted_addresses(vec![sender]); + let addresses_denylist_config = denylist_config_enabled(vec![sender]); + sign_and_submit_tx(chain_spec, addresses_denylist_config, Address::random()) + .await + .expect("Expected RPC submission to accept tx when sender in addresses-exclusions"); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/eip161_empty_account_clearing.rs b/crates/execution-e2e/tests/e2e/eip161_empty_account_clearing.rs new file mode 100644 index 00000000..02e4cd61 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/eip161_empty_account_clearing.rs @@ -0,0 +1,69 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Regression guard for EIP-161 empty-account clearing across the reth 2.2 upgrade. +//! +//! reth 1.11 enabled state clearing via an explicit `set_state_clear_flag(true)` +//! call in `apply_pre_execution_changes`. reth 2.2 removed that hook and relies on +//! revm's Journal to clear touched-but-empty accounts automatically. This test +//! pins the resulting behavior so a future bump that disables clearing — leaving +//! empty accounts in the trie — fails here with a state-root divergence rather +//! than silently forking the network. + +use alloy_primitives::{address, Address, U256}; +use alloy_rpc_types_eth::TransactionRequest; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; +use reth_provider::{AccountReader, StateProviderFactory}; + +/// Fresh address with no genesis allocation. A zero-value call touches it but +/// leaves it empty (nonce 0, balance 0, no code). +const FRESH_EOA: Address = address!("0x00000000000000000000000000000000e1610000"); + +/// A zero-value transaction to a fresh EOA touches the recipient but leaves it +/// empty. The touched empty account is cleared, never persisted as an empty trie +/// entry — the behavior is spec-fixed (Spurious Dragon), identical before and +/// after the reth 2.2 upgrade. +/// +/// Trie membership is the only RPC/state-observable that reflects clearing: +/// `balance`/`nonce`/`code` all read zero whether the account was cleared or +/// persisted empty, so the assertion checks `basic_account` directly. +#[tokio::test] +async fn zero_value_call_to_fresh_eoa_clears_empty_account() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let tx_hash = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(FRESH_EOA)), + value: Some(U256::ZERO), + ..Default::default() + }, + ) + .await?; + node.produce_block().await?; + let receipt = node.get_receipt(tx_hash).await?; + assert!(receipt.status()); + + let state = node.node.inner.provider().latest()?; + let account = state.basic_account(&FRESH_EOA)?; + assert!(account.is_none()); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/eip7708_denylist.rs b/crates/execution-e2e/tests/e2e/eip7708_denylist.rs new file mode 100644 index 00000000..f47a63da --- /dev/null +++ b/crates/execution-e2e/tests/e2e/eip7708_denylist.rs @@ -0,0 +1,175 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 denylist interaction e2e tests. +//! +//! Verifies that the addresses denylist correctly blocks transactions with value +//! transfers to/from denylisted addresses, and that exclusion lists allow +//! transfers with proper EIP-7708 log emission. + +use super::helpers::{ + eip7708::{assert_transfer_log, call_tracer_options}, + utils::send_and_mine, +}; +use alloy_primitives::{address, Address, TxKind, U256}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; +use arc_execution_config::addresses_denylist::{ + AddressesDenylistConfig, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, DENYLIST_ADDRESS_LOCALDEV, +}; +use arc_execution_config::chainspec::ArcChainSpec; +use arc_execution_e2e::{chainspec::localdev_with_denylisted_addresses, ArcSetup, ArcTestNode}; +use jsonrpsee::core::client::Error as RpcClientError; +use reth_e2e_test_utils::wallet::Wallet; +use std::sync::Arc; + +fn denylist_config_enabled(exclusions: Vec
) -> AddressesDenylistConfig { + AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + exclusions, + ) +} + +/// Launches a node with denylist config, signs and submits a value transfer tx. +async fn sign_and_submit_value_tx( + chain_spec: Arc, + addresses_denylist_config: AddressesDenylistConfig, + to: Address, + value: U256, +) -> Result<(), eyre::Report> { + let node = ArcTestNode::start( + ArcSetup::new() + .with_chain_spec(chain_spec.clone()) + .with_addresses_denylist_config(addresses_denylist_config), + ) + .await?; + let signer = node.wallet_signer(0)?; + + node.send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + value: Some(value), + to: Some(TxKind::Call(to)), + gas: Some(26_000), + input: TransactionInput::default(), + ..Default::default() + }, + ) + .await + .map(|_| ()) +} + +fn assert_denylisted_rpc_error(err: &eyre::Report) { + let rpc_err = err + .downcast_ref::() + .expect("Expected JSON-RPC client error"); + let RpcClientError::Call(call_err) = rpc_err else { + panic!("Expected JSON-RPC call error, got: {rpc_err:?}"); + }; + assert!( + call_err.message().to_lowercase().contains("is denylisted"), + "Expected denylist rejection in RPC error, got: {call_err:?}" + ); +} + +/// Test #27: Value transfer to a denylisted address is rejected through RPC submission. +#[tokio::test] +async fn test_value_transfer_to_denylisted_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let denylisted_to = address!("0xdead000000000000000000000000000000000001"); + let chain_spec = localdev_with_denylisted_addresses(vec![denylisted_to]); + let addresses_denylist_config = denylist_config_enabled(Vec::new()); + + let err = sign_and_submit_value_tx( + chain_spec, + addresses_denylist_config, + denylisted_to, + U256::from(1_000_000), + ) + .await + .expect_err("Expected RPC submission to reject tx to denylisted address"); + + assert_denylisted_rpc_error(&err); + + Ok(()) +} + +/// Test #28: Value transfer from a denylisted sender is rejected through RPC submission. +#[tokio::test] +async fn test_value_transfer_from_denylisted_rejected() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let sender = Wallet::new(1).wallet_gen()[0].address(); + let chain_spec = localdev_with_denylisted_addresses(vec![sender]); + let addresses_denylist_config = denylist_config_enabled(Vec::new()); + + let err = sign_and_submit_value_tx( + chain_spec, + addresses_denylist_config, + Address::random(), + U256::from(1_000_000), + ) + .await + .expect_err("Expected RPC submission to reject tx from denylisted address"); + + assert_denylisted_rpc_error(&err); + + Ok(()) +} + +/// Test #29: Excluded address can send value transfer and EIP-7708 log is emitted. +/// +/// When denylist is enabled but the sender is in the exclusion list, +/// the transfer proceeds and the standard EIP-7708 Transfer log is emitted. +#[tokio::test] +async fn test_denylist_exclusion_allows_transfer_with_log() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000CAFE"); + let value = U256::from(1_000_000); + + let sender = Wallet::new(1).wallet_gen()[0].address(); + let chain_spec = localdev_with_denylisted_addresses(vec![sender]); + let addresses_denylist_config = denylist_config_enabled(vec![sender]); + + let mut node = ArcTestNode::start( + ArcSetup::new() + .with_chain_spec(chain_spec) + .with_addresses_denylist_config(addresses_denylist_config), + ) + .await?; + let signer = node.wallet_signer(0)?; + assert_eq!(signer.address(), sender); + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, recipient, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/eip7708_edge_cases.rs b/crates/execution-e2e/tests/e2e/eip7708_edge_cases.rs new file mode 100644 index 00000000..fe0221f1 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/eip7708_edge_cases.rs @@ -0,0 +1,337 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 edge case e2e tests. +//! +//! Tests revert rollback, multi-log composition, inner-call semantics, +//! and unusual transfer patterns. + +use super::helpers; + +use super::helpers::{ + contracts::right_pad_address, + eip7708::{assert_transfer_log, call_tracer_options, NATIVE_COIN_AUTHORITY_ADDRESS}, + utils::{deploy_and_mine, send_and_mine}, +}; +use alloy_primitives::{address, U256}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; +use rstest::rstest; + +/// Test #48: Send value to a reverting contract — tx reverts, no EIP-7708 log. +/// +/// When the entire CALL frame reverts, the EIP-7708 log is rolled back. +/// Deploys an actual reverting contract rather than using an existing address. +#[tokio::test] +async fn test_reverted_call_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let value = U256::from(1_000_000); + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::reverting_contract_deploy_code(), + U256::ZERO, + 100_000, + ) + .await?; + + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(contract)), + value: Some(value), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Tests #49/#50: Inner CALL reverts but outer succeeds — outer log emitted, inner log rolled back. +/// +/// Deploys a reverting contract and an outer contract that forwards value to it. +/// The outer contract accepts value (emitting sender→outer log), then makes an +/// inner CALL with value to the reverting contract. The inner frame reverts, so +/// the inner value transfer log (outer→reverting) is rolled back. Only the +/// outer log remains. Parameterized over transfer amount to verify consistency. +#[rstest] +#[case::standard_value(U256::from(1_000_000))] +#[case::smaller_value(U256::from(500_000))] +#[tokio::test] +async fn test_inner_call_reverts_outer_succeeds(#[case] value: U256) -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (reverting_contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::reverting_contract_deploy_code(), + U256::ZERO, + 200_000, + ) + .await?; + let (outer_contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::call_target_with_value_contract_deploy_code(), + U256::ZERO, + 200_000, + ) + .await?; + + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(outer_contract)), + value: Some(value), + gas: Some(200_000), + input: TransactionInput::new(right_pad_address(reverting_contract)), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, outer_contract, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #51: Multiple sequential value transfers in separate blocks. +/// +/// Each block contains a value transfer, verifying logs are emitted consistently +/// across blocks and don't leak between transactions. +#[tokio::test] +async fn test_sequential_blocks_each_emit_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient_1 = address!("0x000000000000000000000000000000000000AAA1"); + let recipient_2 = address!("0x000000000000000000000000000000000000AAA2"); + let value_1 = U256::from(100_000); + let value_2 = U256::from(200_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt_1 = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient_1)), + value: Some(value_1), + ..Default::default() + }, + ) + .await?; + assert!(receipt_1.status()); + assert_eq!(receipt_1.logs().len(), 1); + assert_transfer_log(&receipt_1, 0, sender, recipient_1, value_1); + node.trace_transaction(receipt_1.transaction_hash, call_tracer_options()) + .await?; + + let receipt_2 = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient_2)), + value: Some(value_2), + ..Default::default() + }, + ) + .await?; + assert!(receipt_2.status()); + assert_eq!(receipt_2.logs().len(), 1); + assert_transfer_log(&receipt_2, 0, sender, recipient_2, value_2); + node.trace_transaction(receipt_2.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #52: Contract calls NativeCoinAuthority precompile with value. +/// +/// Deploys a contract that forwards a CALL with value to the NativeCoinAuthority +/// precompile address. The precompile will revert (unauthorized caller), but +/// the outer frame succeeds. The outer value transfer log (sender→contract) +/// is preserved; the inner log (contract→precompile) is rolled back because +/// the precompile rejects the call. +#[tokio::test] +async fn test_contract_calls_precompile_with_value() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let value = U256::from(500_000); + let calldata = right_pad_address(NATIVE_COIN_AUTHORITY_ADDRESS); + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::call_target_with_value_contract_deploy_code(), + U256::ZERO, + 200_000, + ) + .await?; + + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(contract)), + value: Some(value), + gas: Some(200_000), + input: TransactionInput::new(calldata), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, contract, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #53: Value transfer after producing multiple empty blocks. +/// +/// Verifies that EIP-7708 log emission works correctly even when +/// there are empty blocks between genesis and the transfer. +#[tokio::test] +async fn test_log_after_empty_blocks() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000CC0001"); + let value = U256::from(500_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + node.produce_blocks(5).await?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, recipient, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test: Transfer to a contract that exists but has no code (EOA-like). +#[tokio::test] +async fn test_transfer_to_codeless_address() -> Result<()> { + reth_tracing::init_test_tracing(); + + let target = address!("0x000000000000000000000000000000000000DEAD"); + let value = U256::from(1_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(target)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, target, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test: Value transfer and zero-value transfer in same block. +/// +/// Only the value transfer should emit a log; the zero-value transfer should not. +#[tokio::test] +async fn test_mixed_value_and_zero_value_in_block() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000F00D"); + let value = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let with_value = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + let zero_value = node + .send_tx( + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(recipient)), + value: Some(U256::ZERO), + ..Default::default() + }, + ) + .await?; + node.produce_block().await?; + + let with_value_receipt = node.get_receipt(with_value).await?; + assert!(with_value_receipt.status()); + assert_eq!(with_value_receipt.logs().len(), 1); + assert_transfer_log(&with_value_receipt, 0, sender, recipient, value); + + let zero_value_receipt = node.get_receipt(zero_value).await?; + assert!(zero_value_receipt.status()); + assert_eq!(zero_value_receipt.logs().len(), 0); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/eip7708_hardfork_transition.rs b/crates/execution-e2e/tests/e2e/eip7708_hardfork_transition.rs new file mode 100644 index 00000000..339f89c7 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/eip7708_hardfork_transition.rs @@ -0,0 +1,207 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 baseline e2e tests. +//! +//! Verifies that EIP-7708 Transfer logs are emitted even when chain metadata +//! schedules Zero5 later. + +use super::helpers::{ + eip7708::{assert_transfer_log, call_tracer_options}, + utils::send_and_mine, +}; +use alloy_primitives::{address, U256}; +use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionRequest}; +use arc_execution_config::hardforks::{is_arc_fork_active, ArcHardfork}; +use arc_execution_e2e::{chainspec::localdev_with_hardforks, ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; +use reth_chainspec::{ChainSpecProvider, ForkCondition}; + +/// Test #20: Value transfer emits EIP-7708 Transfer before Zero5 metadata activation. +#[tokio::test] +async fn test_baseline_emits_eip7708_before_zero5_metadata_activation() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = localdev_with_hardforks(&[ + (ArcHardfork::Zero3, ForkCondition::Block(0)), + (ArcHardfork::Zero4, ForkCondition::Block(0)), + (ArcHardfork::Zero5, ForkCondition::Block(100)), // far in the future + (ArcHardfork::Zero6, ForkCondition::Block(100)), + ]); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + let value = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new().with_chain_spec(chain_spec)).await?; + let chain_spec = node.node.inner.provider().chain_spec(); + let current = node.get_block(BlockNumberOrTag::Latest).await?; + assert!(!is_arc_fork_active( + chain_spec.as_ref(), + ArcHardfork::Zero5, + current.header.number, + current.header.timestamp + )); + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, recipient, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #21: Zero5 activation metadata does not change the EIP-7708 baseline. +#[tokio::test] +async fn test_zero5_activation_metadata_keeps_eip7708_baseline() -> Result<()> { + reth_tracing::init_test_tracing(); + + // Zero5 activates at block 3 + let chain_spec = localdev_with_hardforks(&[ + (ArcHardfork::Zero3, ForkCondition::Block(0)), + (ArcHardfork::Zero4, ForkCondition::Block(0)), + (ArcHardfork::Zero5, ForkCondition::Block(3)), + (ArcHardfork::Zero6, ForkCondition::Block(100)), + ]); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + let value = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new().with_chain_spec(chain_spec)).await?; + let chain_spec = node.node.inner.provider().chain_spec(); + let current = node.get_block(BlockNumberOrTag::Latest).await?; + assert!(!is_arc_fork_active( + chain_spec.as_ref(), + ArcHardfork::Zero5, + current.header.number, + current.header.timestamp + )); + + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let pre_zero5_receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + let current = node.get_block(BlockNumberOrTag::Latest).await?; + assert_eq!(current.header.number, 1); + assert!(pre_zero5_receipt.status()); + assert_eq!(pre_zero5_receipt.logs().len(), 1); + assert_transfer_log(&pre_zero5_receipt, 0, sender, recipient, value); + + node.produce_blocks(2).await?; + let current = node.get_block(BlockNumberOrTag::Latest).await?; + assert_eq!(current.header.number, 3); + assert!(is_arc_fork_active( + chain_spec.as_ref(), + ArcHardfork::Zero5, + current.header.number, + current.header.timestamp + )); + + let post_zero5_receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(post_zero5_receipt.status()); + assert_eq!(post_zero5_receipt.logs().len(), 1); + assert_transfer_log(&post_zero5_receipt, 0, sender, recipient, value); + node.trace_transaction(pre_zero5_receipt.transaction_hash, call_tracer_options()) + .await?; + node.trace_transaction(post_zero5_receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #22: Post-Zero5 value transfer emits Transfer from SYSTEM_ADDRESS. +#[tokio::test] +async fn test_post_zero5_emits_eip7708_transfer() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + let value = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let chain_spec = node.node.inner.provider().chain_spec(); + let current = node.get_block(BlockNumberOrTag::Latest).await?; + assert!(is_arc_fork_active( + chain_spec.as_ref(), + ArcHardfork::Zero5, + current.header.number, + current.header.timestamp + )); + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, recipient, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #23: Verify Zero5 hardfork is active at genesis on default localdev. +#[tokio::test] +async fn test_zero5_active_at_genesis() -> Result<()> { + reth_tracing::init_test_tracing(); + + let node = ArcTestNode::start(ArcSetup::new()).await?; + let chain_spec = node.node.inner.provider().chain_spec(); + let current = node.get_block(BlockNumberOrTag::Latest).await?; + assert!(is_arc_fork_active( + chain_spec.as_ref(), + ArcHardfork::Zero5, + current.header.number, + current.header.timestamp + )); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/eip7708_log_format.rs b/crates/execution-e2e/tests/e2e/eip7708_log_format.rs new file mode 100644 index 00000000..b2fd6ac7 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/eip7708_log_format.rs @@ -0,0 +1,208 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 log format compliance e2e tests. +//! +//! Verifies the byte-level ERC-20 Transfer log format: emitter address, +//! topic[0] (event signature), topic[1] (from), topic[2] (to), data (value). + +use super::helpers::{ + eip7708::{call_tracer_options, SYSTEM_ADDRESS, TRANSFER_EVENT_SIGNATURE}, + utils::send_and_mine, +}; +use alloy_primitives::{address, Bytes, U256}; +use alloy_rpc_types_eth::TransactionRequest; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; + +/// Test #37: topic[0] matches ERC-20 Transfer(address,address,uint256) signature. +#[tokio::test] +async fn test_transfer_log_topic0_matches_erc20_signature() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000001111"); + let value = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let expected_topics = vec![ + TRANSFER_EVENT_SIGNATURE, + sender.into_word(), + recipient.into_word(), + ]; + let expected_data = Bytes::from(value.to_be_bytes::<32>().to_vec()); + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + let log = &receipt.logs()[0]; + assert_eq!(log.address(), SYSTEM_ADDRESS); + assert_eq!(log.topics(), expected_topics.as_slice()); + assert_eq!(log.data().data, expected_data); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #38: topic[1] encodes sender address as left-padded bytes32. +#[tokio::test] +async fn test_transfer_log_topic1_encodes_sender() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000002222"); + let value = U256::from(42); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + let log = &receipt.logs()[0]; + let topics = log.topics(); + assert_eq!(topics.len(), 3); + assert_eq!(topics[0], TRANSFER_EVENT_SIGNATURE); + assert_eq!(topics[1], sender.into_word()); + assert_eq!(topics[2], recipient.into_word()); + assert_eq!( + log.data().data.as_ref(), + value.to_be_bytes::<32>().as_slice() + ); + Ok(()) +} + +/// Test #39: topic[2] encodes recipient address as left-padded bytes32. +#[tokio::test] +async fn test_transfer_log_topic2_encodes_recipient() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000003333"); + let value = U256::from(999); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + let log = &receipt.logs()[0]; + let topics = log.topics(); + assert_eq!(topics.len(), 3); + assert_eq!(topics[0], TRANSFER_EVENT_SIGNATURE); + assert_eq!(topics[1], sender.into_word()); + assert_eq!(topics[2], recipient.into_word()); + assert_eq!( + log.data().data.as_ref(), + value.to_be_bytes::<32>().as_slice() + ); + Ok(()) +} + +/// Test #40: data encodes value as big-endian uint256. +#[tokio::test] +async fn test_transfer_log_data_encodes_value() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000004444"); + // Use a distinctive value to verify encoding + let value = U256::from(0xDEADBEEFu64); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + let log = &receipt.logs()[0]; + let topics = log.topics(); + assert_eq!(topics.len(), 3); + assert_eq!(topics[0], TRANSFER_EVENT_SIGNATURE); + assert_eq!(topics[1], sender.into_word()); + assert_eq!(topics[2], recipient.into_word()); + assert_eq!( + log.data().data.as_ref(), + value.to_be_bytes::<32>().as_slice() + ); + Ok(()) +} + +/// Test #41: emitter address is SYSTEM_ADDRESS, not the sender or NativeCoinAuthority. +#[tokio::test] +async fn test_transfer_log_emitter_is_system_address() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000005555"); + let value = U256::from(1); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + let log = &receipt.logs()[0]; + assert_eq!(log.address(), SYSTEM_ADDRESS); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/eip7708_native_transfer.rs b/crates/execution-e2e/tests/e2e/eip7708_native_transfer.rs new file mode 100644 index 00000000..3750cdce --- /dev/null +++ b/crates/execution-e2e/tests/e2e/eip7708_native_transfer.rs @@ -0,0 +1,929 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 native transfer e2e tests. +//! +//! Tests that native value transfers emit ERC-20 Transfer logs from SYSTEM_ADDRESS +//! under the Zero5 hardfork via CALL to EOA, contract, and precompile recipients, +//! as well as CREATE, SELFDESTRUCT, and nested value transfer scenarios. + +use super::helpers; + +use super::helpers::{ + contracts::right_pad_address, + eip7708::{assert_transfer_log, call_tracer_options}, + utils::{deploy_and_mine, send_and_mine}, +}; +use alloy_primitives::{address, TxHash, U256}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; +use alloy_rpc_types_trace::geth::{GethDebugTracingOptions, GethDefaultTracingOptions, GethTrace}; +use arc_execution_config::hardforks::ArcHardfork; +use arc_execution_e2e::{chainspec::localdev_with_hardforks, ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; +use reth_chainspec::ForkCondition; + +fn opcode_gas_trace_options() -> GethDebugTracingOptions { + GethDebugTracingOptions { + config: GethDefaultTracingOptions::default() + .with_enable_memory(false) + .disable_stack() + .disable_storage(), + ..Default::default() + } +} + +async fn assert_last_opcode_gas_cost( + node: &ArcTestNode, + hash: TxHash, + opcode: &str, + expected: u64, +) -> Result<()> { + let trace = node + .trace_transaction(hash, opcode_gas_trace_options()) + .await?; + let GethTrace::Default(frame) = trace else { + return Err(eyre::eyre!( + "expected default struct-log trace for transaction {hash}" + )); + }; + let log = frame + .struct_logs + .iter() + .rev() + .find(|log| log.opcode() == opcode) + .ok_or_else(|| eyre::eyre!("opcode {opcode} not found in trace for transaction {hash}"))?; + + assert_eq!(log.gas_cost, expected); + Ok(()) +} + +// ===== CALL to EOA (#1-3) ===== + +/// Test #1: EOA sends nonzero USDC to another EOA — emits 1 EIP-7708 Transfer log. +#[tokio::test] +async fn test_call_eoa_with_value_emits_eip7708_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + let value = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.gas_used, 21_000); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, recipient, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #2: EOA sends 0 value — no EIP-7708 log. +#[tokio::test] +async fn test_call_eoa_zero_value_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x000000000000000000000000000000000000bEEF"); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(recipient)), + value: Some(U256::ZERO), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.gas_used, 21_000); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #3: EOA sends value to self — no EIP-7708 log (self-transfer is suppressed). +#[tokio::test] +async fn test_call_eoa_self_transfer_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let value = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(sender)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.gas_used, 21_000); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +// ===== CALL to Contract (#4-6) ===== + +/// Test #4: EOA sends value to a value-accepting contract — emits exact EIP-7708 Transfer log. +#[tokio::test] +async fn test_call_contract_with_value_emits_eip7708_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let transfer_value = U256::from(500_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::payable_contract_deploy_code(), + U256::ZERO, + 100_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + value: Some(transfer_value), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, contract, transfer_value); + let balance = node.balance(contract, None).await?; + assert_eq!(balance, transfer_value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #5: EOA sends 0 value to a contract — no EIP-7708 log. +#[tokio::test] +async fn test_call_contract_zero_value_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::payable_contract_deploy_code(), + U256::ZERO, + 100_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + value: Some(U256::ZERO), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #6: EOA sends value to a reverting contract — tx reverts, no EIP-7708 log. +#[tokio::test] +async fn test_call_reverting_contract_with_value_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let value = U256::from(500_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::reverting_contract_deploy_code(), + U256::ZERO, + 100_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + value: Some(value), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +// ===== CALL to Precompile (#7-8) ===== + +/// Test #7: CALL to precompile with value — reverts (unauthorized), logs rolled back. +#[tokio::test] +async fn test_call_precompile_with_value() -> Result<()> { + reth_tracing::init_test_tracing(); + + let precompile = address!("0x1800000000000000000000000000000000000000"); + let value = U256::from(1_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(precompile)), + value: Some(value), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #8: CALL to precompile with 0 value — no EIP-7708 log. +#[tokio::test] +async fn test_call_precompile_zero_value_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let precompile = address!("0x1800000000000000000000000000000000000000"); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(precompile)), + value: Some(U256::ZERO), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +// ===== CREATE (#9-10) ===== + +/// Test #9: CREATE with nonzero value — emits exact EIP-7708 Transfer log. +#[tokio::test] +async fn test_create_with_value_emits_eip7708_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let endowment = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (contract, receipt) = deploy_and_mine( + &mut node, + signer, + helpers::contracts::payable_contract_deploy_code(), + endowment, + 100_000, + ) + .await?; + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, contract, endowment); + let balance = node.balance(contract, None).await?; + assert_eq!(balance, endowment); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #10: CREATE with zero value — no EIP-7708 Transfer log. +#[tokio::test] +async fn test_create_zero_value_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let (_, receipt) = deploy_and_mine( + &mut node, + signer, + helpers::contracts::payable_contract_deploy_code(), + U256::ZERO, + 100_000, + ) + .await?; + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test: CREATE with nonzero value where constructor reverts — tx reverts, no log, no balance leak. +#[tokio::test] +async fn test_create_revert_with_endowment_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let initcode = helpers::contracts::reverting_constructor_code(); + let endowment = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let would_be_addr = sender.create(0); + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Create), + value: Some(endowment), + gas: Some(100_000), + input: TransactionInput::new(initcode), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + let balance = node.balance(would_be_addr, None).await?; + assert_eq!(balance, U256::ZERO); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test: successful CREATE2 with value leaves the created address warm. +#[tokio::test] +async fn test_create2_with_value_balance_probe_is_warm_after_successful_create() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (probe, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::create2_with_balance_probe(), + U256::from(1), + 200_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(probe)), + gas: Some(200_000), + input: TransactionInput::new(helpers::contracts::create2_balance_probe_calldata(probe)), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_last_opcode_gas_cost(&node, receipt.transaction_hash, "BALANCE", 100).await?; + Ok(()) +} + +/// Test: out-of-funds CREATE2 with value does not warm the would-be created address. +#[tokio::test] +async fn test_create2_out_of_funds_keeps_balance_probe_cold() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (probe, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::create2_with_balance_probe(), + U256::ZERO, + 200_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(probe)), + gas: Some(200_000), + input: TransactionInput::new(helpers::contracts::create2_balance_probe_calldata(probe)), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_last_opcode_gas_cost(&node, receipt.transaction_hash, "BALANCE", 2600).await?; + Ok(()) +} + +// ===== SELFDESTRUCT (#11-18) ===== + +/// Test #11: SELFDESTRUCT sends balance to beneficiary — emits exact EIP-7708 Transfer log. +#[tokio::test] +async fn test_selfdestruct_with_balance_emits_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let endowment = U256::from(1_000_000); + let beneficiary = address!("0x000000000000000000000000000000000000BEEF"); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::selfdestruct_contract_deploy_code(), + endowment, + 200_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + gas: Some(200_000), + input: TransactionInput::new(right_pad_address(beneficiary)), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, contract, beneficiary, endowment); + let balance = node.balance(contract, None).await?; + assert_eq!(balance, U256::ZERO); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #12: SELFDESTRUCT with zero balance — no EIP-7708 Transfer log. +#[tokio::test] +async fn test_selfdestruct_zero_balance_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let beneficiary = address!("0x000000000000000000000000000000000000BEEF"); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::selfdestruct_contract_deploy_code(), + U256::ZERO, + 200_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + gas: Some(200_000), + input: TransactionInput::new(right_pad_address(beneficiary)), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #13: SELFDESTRUCT to self — beneficiary == contract address. +#[tokio::test] +async fn test_selfdestruct_to_self_reverts() -> Result<()> { + reth_tracing::init_test_tracing(); + + let endowment = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::selfdestruct_contract_deploy_code(), + endowment, + 200_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + gas: Some(200_000), + input: TransactionInput::new(right_pad_address(contract)), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + let balance = node.balance(contract, None).await?; + assert_eq!(balance, endowment); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test: before Zero8, SELFDESTRUCT target warmth only honors the legacy warm-address set. +#[tokio::test] +async fn test_selfdestruct_to_transaction_warm_target_before_zero8_reverts() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = localdev_with_hardforks(&[ + (ArcHardfork::Zero3, ForkCondition::Block(0)), + (ArcHardfork::Zero4, ForkCondition::Block(0)), + (ArcHardfork::Zero5, ForkCondition::Block(0)), + (ArcHardfork::Zero6, ForkCondition::Block(0)), + (ArcHardfork::Zero7, ForkCondition::Block(0)), + (ArcHardfork::Zero8, ForkCondition::Block(100)), + ]); + let endowment = U256::from(1); + let selfdestruct_target = address!("0x000000000000000000000000000000000000BEEF"); + + let mut node = ArcTestNode::start(ArcSetup::new().with_chain_spec(chain_spec)).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let fund_receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(selfdestruct_target)), + value: Some(U256::from(1)), + ..Default::default() + }, + ) + .await?; + assert!(fund_receipt.status()); + + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::balance_warming_selfdestruct_contract_deploy_code(), + endowment, + 200_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + value: Some(U256::ZERO), + gas: Some(28_869), + input: TransactionInput::new(right_pad_address(selfdestruct_target)), + ..Default::default() + }, + ) + .await?; + + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + assert_eq!( + node.balance(selfdestruct_target, None).await?, + U256::from(1) + ); + assert_eq!(node.balance(contract, None).await?, endowment); + Ok(()) +} + +/// Test: SELFDESTRUCT to a target warmed earlier in the same transaction succeeds +/// even when the remaining gas after SELFDESTRUCT's static cost is below the cold +/// account load cost. +#[tokio::test] +async fn test_selfdestruct_to_transaction_warm_target_with_low_gas_succeeds() -> Result<()> { + reth_tracing::init_test_tracing(); + + let endowment = U256::from(1); + let selfdestruct_target = address!("0x000000000000000000000000000000000000BEEF"); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let fund_receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(selfdestruct_target)), + value: Some(U256::from(1)), + ..Default::default() + }, + ) + .await?; + assert!(fund_receipt.status()); + + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::balance_warming_selfdestruct_contract_deploy_code(), + endowment, + 200_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + value: Some(U256::ZERO), + gas: Some(28_869), + input: TransactionInput::new(right_pad_address(selfdestruct_target)), + ..Default::default() + }, + ) + .await?; + + assert!(receipt.status()); + assert_last_opcode_gas_cost(&node, receipt.transaction_hash, "SELFDESTRUCT", 5000).await?; + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, contract, selfdestruct_target, endowment); + assert_eq!( + node.balance(selfdestruct_target, None).await?, + U256::from(2) + ); + assert_eq!(node.balance(contract, None).await?, U256::ZERO); + Ok(()) +} + +// ===== Nested/Forwarded Transfer (#19) ===== + +/// Test #19: Contract forwards received value to another address — both transfers emit exact logs. +#[tokio::test] +async fn test_nested_value_transfer_emits_multiple_logs() -> Result<()> { + reth_tracing::init_test_tracing(); + + let final_recipient = address!("0x000000000000000000000000000000000000CAFE"); + let value = U256::from(500_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (forwarder, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::forwarder_contract_deploy_code(), + U256::ZERO, + 200_000, + ) + .await?; + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(forwarder)), + value: Some(value), + gas: Some(200_000), + input: TransactionInput::new(right_pad_address(final_recipient)), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 2); + assert_transfer_log(&receipt, 0, sender, forwarder, value); + assert_transfer_log(&receipt, 1, forwarder, final_recipient, value); + let balance = node.balance(forwarder, None).await?; + assert_eq!(balance, U256::ZERO); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +// ===== Additional coverage ===== + +/// Test: large value transfer emits correct log. +#[tokio::test] +async fn test_large_value_transfer() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000001234"); + let value = U256::from(10_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, recipient, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test: minimum value (1 wei) transfer emits correct log. +#[tokio::test] +async fn test_min_value_transfer() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = address!("0x0000000000000000000000000000000000005678"); + let value = U256::from(1); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(recipient)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 1); + assert_transfer_log(&receipt, 0, sender, recipient, value); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test: multiple value transfers in one block each emit their own log. +#[tokio::test] +async fn test_multiple_transfers_in_block() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient_a = address!("0x000000000000000000000000000000000000aaaa"); + let recipient_b = address!("0x000000000000000000000000000000000000bbbb"); + let value_a = U256::from(100_000); + let value_b = U256::from(200_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let tx1 = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(recipient_a)), + value: Some(value_a), + ..Default::default() + }, + ) + .await?; + let tx2 = node + .send_tx( + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(recipient_b)), + value: Some(value_b), + ..Default::default() + }, + ) + .await?; + node.produce_block().await?; + + let receipt1 = node.get_receipt(tx1).await?; + assert!(receipt1.status()); + assert_eq!(receipt1.gas_used, 21_000); + assert_eq!(receipt1.logs().len(), 1); + assert_transfer_log(&receipt1, 0, sender, recipient_a, value_a); + + let receipt2 = node.get_receipt(tx2).await?; + assert!(receipt2.status()); + assert_eq!(receipt2.gas_used, 21_000); + assert_eq!(receipt2.logs().len(), 1); + assert_transfer_log(&receipt2, 0, sender, recipient_b, value_b); + node.trace_transaction(tx1, call_tracer_options()).await?; + node.trace_transaction(tx2, call_tracer_options()).await?; + Ok(()) +} + +/// Test: reverted value transfer to reverting contract does not leak balance. +#[tokio::test] +async fn test_reverted_value_transfer_balance_unchanged() -> Result<()> { + reth_tracing::init_test_tracing(); + + let value = U256::from(500_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + let (contract, _) = deploy_and_mine( + &mut node, + signer.clone(), + helpers::contracts::reverting_contract_deploy_code(), + U256::ZERO, + 100_000, + ) + .await?; + let starting_balance = node.balance(contract, None).await?; + assert_eq!(starting_balance, U256::ZERO); + let receipt = send_and_mine( + &mut node, + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + value: Some(value), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + let ending_balance = node.balance(contract, None).await?; + assert_eq!(ending_balance, U256::ZERO); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/eip7708_payload_validation.rs b/crates/execution-e2e/tests/e2e/eip7708_payload_validation.rs new file mode 100644 index 00000000..cfe943c8 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/eip7708_payload_validation.rs @@ -0,0 +1,122 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 payload validation e2e tests. +//! +//! Verifies that Engine API accepts payloads containing EIP-7708 Transfer logs +//! and rejects payloads with corrupted state roots. + +use alloy_primitives::{address, B256, U256}; +use alloy_rpc_types_engine::PayloadStatusEnum; +use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionRequest}; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; + +use super::helpers::{ + payload::{forkchoice_state, mutate_payload, next_payload_attributes}, + utils::send_and_mine, +}; + +/// Test #42: Payload with EIP-7708 Transfer log is accepted as VALID. +#[tokio::test] +async fn test_payload_with_eip7708_log_accepted() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + + // Produce block 1 with a value transfer (triggers EIP-7708 log) + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(address!( + "0x000000000000000000000000000000000000bEEF" + ))), + value: Some(U256::from(1_000_000)), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + + // Now build the next payload and submit via Engine API. + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let payload = node.get_payload(payload_id).await?; + + let status = node.new_payload(payload).await?; + assert!(matches!(status, PayloadStatusEnum::Valid)); + + Ok(()) +} + +/// Test #43: Payload with corrupted stateRoot after EIP-7708 tx is rejected as INVALID. +#[tokio::test] +async fn test_payload_with_corrupted_state_root_rejected() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + // Produce block 1 with a value transfer + send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(address!( + "0x000000000000000000000000000000000000bEEF" + ))), + value: Some(U256::from(1_000_000)), + ..Default::default() + }, + ) + .await?; + + // Build next payload. + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let mut payload = node.get_payload(payload_id).await?; + + // Corrupt the state root + mutate_payload(&mut payload, |payload| { + payload.as_v1_mut().state_root = B256::repeat_byte(0xDE); + })?; + + let status = node.new_payload(payload).await?; + + assert!( + matches!(status, PayloadStatusEnum::Invalid { .. }), + "Expected INVALID status for corrupted state root, got {status:?}" + ); + + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/eip7708_precompile.rs b/crates/execution-e2e/tests/e2e/eip7708_precompile.rs new file mode 100644 index 00000000..44f9835d --- /dev/null +++ b/crates/execution-e2e/tests/e2e/eip7708_precompile.rs @@ -0,0 +1,487 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 precompile interaction e2e tests. +//! +//! Tests cover both unauthorized (revert) and authorized (success) paths for +//! NativeCoinAuthority precompile operations (mint, burn, transfer). +//! +//! Under Zero5, the NativeCoinAuthority precompile only accepts calls from +//! `NATIVE_FIAT_TOKEN_ADDRESS` (0x3600..0000). Direct EOA calls are rejected. +//! Authorized calls go through the NativeFiatToken contract, which delegates +//! to the precompile. The operator wallet (index 7 in localdev genesis) has +//! the minter role. + +use super::helpers::{ + eip7708::{call_tracer_options, NATIVE_COIN_AUTHORITY_ADDRESS, TRANSFER_EVENT_SIGNATURE}, + utils::send_and_mine, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; +use alloy_sol_types::{sol, SolCall}; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; + +/// NativeFiatToken proxy contract address — the only caller authorized to invoke +/// NativeCoinAuthority under Zero5. +const NATIVE_FIAT_TOKEN_ADDRESS: Address = address!("0x3600000000000000000000000000000000000000"); + +/// NativeCoinControl precompile address. +const NATIVE_COIN_CONTROL_ADDRESS: Address = address!("0x1800000000000000000000000000000000000001"); + +/// Operator wallet index in localdev genesis (has minter role on NativeFiatToken). +const WALLET_OPERATOR_INDEX: usize = 7; + +/// NativeFiatToken uses 6 decimals; the precompile operates in 18-decimal native units. +/// NativeFiatToken converts by multiplying by 10^12 before calling the precompile. +/// So 1 USDC (1_000_000 in 6-dec) becomes 10^18 in the precompile's event and balance. +const USDC_TO_NATIVE: U256 = U256::from_limbs([1_000_000_000_000u64, 0, 0, 0]); // 10^12 + +sol! { + /// NativeFiatToken contract ABI (authorized path — operator calls these). + interface INativeFiatToken { + function mint(address to, uint256 amount) public; + function burn(uint256 amount) public; + function transfer(address to, uint256 amount) public returns (bool); + } + + /// NativeCoinAuthority precompile ABI (unauthorized path — direct calls). + interface INativeCoinAuthority { + function mint(address to, uint256 amount) external returns (bool); + function burn(address from, uint256 amount) external returns (bool); + function transfer(address from, address to, uint256 amount) external returns (bool); + function totalSupply() external view returns (uint256 supply); + } +} + +fn native_units(usdc_amount: U256) -> U256 { + usdc_amount + .checked_mul(USDC_TO_NATIVE) + .expect("usdc to native overflow") +} + +// ===== Unauthorized paths (#30-32): Direct EOA calls to precompile ===== + +/// Test #30: Direct unauthorized call to NativeCoinAuthority mint — reverts, no EIP-7708 log. +#[tokio::test] +async fn test_unauthorized_mint_call_reverts_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let calldata = INativeCoinAuthority::mintCall { + to: address!("0x000000000000000000000000000000000000bEEF"), + amount: U256::from(1_000_000), + } + .abi_encode(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NATIVE_COIN_AUTHORITY_ADDRESS)), + value: Some(U256::ZERO), + input: TransactionInput::new(Bytes::from(calldata)), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #31: Direct unauthorized call to NativeCoinAuthority burn — reverts, no EIP-7708 log. +#[tokio::test] +async fn test_unauthorized_burn_call_reverts_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let calldata = INativeCoinAuthority::burnCall { + from: address!("0x000000000000000000000000000000000000bEEF"), + amount: U256::from(1_000), + } + .abi_encode(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NATIVE_COIN_AUTHORITY_ADDRESS)), + value: Some(U256::ZERO), + input: TransactionInput::new(Bytes::from(calldata)), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #32: Direct unauthorized call to NativeCoinAuthority transfer — reverts, no EIP-7708 log. +#[tokio::test] +async fn test_unauthorized_transfer_call_reverts_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let calldata = INativeCoinAuthority::transferCall { + from: address!("0x000000000000000000000000000000000000bEEF"), + to: address!("0x000000000000000000000000000000000000CAFE"), + amount: U256::from(1_000), + } + .abi_encode(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NATIVE_COIN_AUTHORITY_ADDRESS)), + value: Some(U256::ZERO), + input: TransactionInput::new(Bytes::from(calldata)), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +// ===== Value to precompile addresses (#33-34) ===== + +/// Test #33: Value transfer to NativeCoinAuthority — reverts, no log. +#[tokio::test] +async fn test_value_to_native_coin_authority() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NATIVE_COIN_AUTHORITY_ADDRESS)), + value: Some(U256::from(1_000)), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #34: Value transfer to NativeCoinControl — reverts, no log. +#[tokio::test] +async fn test_value_to_native_coin_control() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NATIVE_COIN_CONTROL_ADDRESS)), + value: Some(U256::from(1_000)), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #35: Zero-value call to NativeFiatToken — no EIP-7708 log. +#[tokio::test] +async fn test_zero_value_call_to_native_fiat_token() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NATIVE_FIAT_TOKEN_ADDRESS)), + value: Some(U256::ZERO), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #36: Direct totalSupply read — succeeds without log. +#[tokio::test] +async fn test_total_supply_read_no_log() -> Result<()> { + reth_tracing::init_test_tracing(); + + let calldata = Bytes::from(INativeCoinAuthority::totalSupplyCall {}.abi_encode()); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NATIVE_COIN_AUTHORITY_ADDRESS)), + value: Some(U256::ZERO), + input: TransactionInput::new(calldata), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +// ===== Authorized paths: NativeFiatToken mint/burn ===== + +/// Test: Authorized mint via NativeFiatToken — emits EIP-7708 Transfer log + Mint event. +/// +/// The operator (wallet index 7) calls NativeFiatToken.mint(to, amount). +/// NativeFiatToken delegates to NativeCoinAuthority precompile. +/// Under Zero5, the precompile emits an EIP-7708 Transfer log from SYSTEM_ADDRESS +/// for the minted amount, plus the Solidity-level Mint and Transfer events from +/// the NativeFiatToken contract. +#[tokio::test] +async fn test_authorized_mint_via_native_fiat_token() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mint_recipient = address!("0x000000000000000000000000000000000000CAFE"); + // NativeFiatToken uses 6 decimals. Mint 1 USDC = 1_000_000 (6 decimals). + // The precompile converts this to 18-decimal native units internally. + let mint_amount_usdc = U256::from(1_000_000u64); + + let calldata = INativeFiatToken::mintCall { + to: mint_recipient, + amount: mint_amount_usdc, + } + .abi_encode(); + + let native_amount = native_units(mint_amount_usdc); + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let operator = node.wallet_signer(WALLET_OPERATOR_INDEX)?; + let receipt = send_and_mine( + &mut node, + operator.clone(), + TransactionRequest { + from: Some(operator.address()), + to: Some(TxKind::Call(NATIVE_FIAT_TOKEN_ADDRESS)), + value: Some(U256::ZERO), + input: TransactionInput::new(Bytes::from(calldata)), + gas: Some(500_000), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + let log = &receipt.logs()[0]; + let topics = log.topics(); + assert_eq!(topics.len(), 3); + assert_eq!(topics[0], TRANSFER_EVENT_SIGNATURE); + assert_eq!(topics[1], Address::ZERO.into_word()); + assert_eq!(topics[2], mint_recipient.into_word()); + assert_eq!( + log.data().data.as_ref(), + native_amount.to_be_bytes::<32>().as_slice() + ); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + let balance = node.balance(mint_recipient, None).await?; + assert_eq!(balance, native_amount); + Ok(()) +} + +/// Test: Authorized burn via NativeFiatToken — emits EIP-7708 Transfer log + Burn event. +/// +/// Burns tokens from the operator's own balance. Requires the operator to have +/// balance, so we first mint to the operator, then burn. +#[tokio::test] +async fn test_authorized_burn_via_native_fiat_token() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mint_amount = U256::from(2_000_000u64); // 2 USDC + let burn_amount = U256::from(1_000_000u64); // 1 USDC + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let operator = node.wallet_signer(WALLET_OPERATOR_INDEX)?; + // Mint to operator first + let mint_calldata = INativeFiatToken::mintCall { + to: operator.address(), + amount: mint_amount, + } + .abi_encode(); + + let burn_calldata = INativeFiatToken::burnCall { + amount: burn_amount, + } + .abi_encode(); + + let mint_receipt = send_and_mine( + &mut node, + operator.clone(), + TransactionRequest { + from: Some(operator.address()), + to: Some(TxKind::Call(NATIVE_FIAT_TOKEN_ADDRESS)), + value: Some(U256::ZERO), + input: TransactionInput::new(Bytes::from(mint_calldata)), + gas: Some(500_000), + ..Default::default() + }, + ) + .await?; + assert!(mint_receipt.status()); + + let burn_receipt = send_and_mine( + &mut node, + operator.clone(), + TransactionRequest { + from: Some(operator.address()), + to: Some(TxKind::Call(NATIVE_FIAT_TOKEN_ADDRESS)), + value: Some(U256::ZERO), + input: TransactionInput::new(Bytes::from(burn_calldata)), + gas: Some(500_000), + ..Default::default() + }, + ) + .await?; + assert!(burn_receipt.status()); + let log = &burn_receipt.logs()[0]; + let topics = log.topics(); + assert_eq!(topics.len(), 3); + assert_eq!(topics[0], TRANSFER_EVENT_SIGNATURE); + assert_eq!(topics[1], operator.address().into_word()); + assert_eq!(topics[2], Address::ZERO.into_word()); + assert_eq!( + log.data().data.as_ref(), + native_units(burn_amount).to_be_bytes::<32>().as_slice() + ); + node.trace_transaction(burn_receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test: Authorized transfer via NativeFiatToken — emits exact EIP-7708 Transfer log. +/// +/// Mints to the operator, then the operator calls NativeFiatToken.transfer(to, amount). +/// NativeFiatToken delegates to NativeCoinAuthority.transfer(from, to, amount). +/// Under Zero5, the precompile emits Transfer(from, to, amount) from SYSTEM_ADDRESS. +/// Verifies exact log fields and balance side effects. +#[tokio::test] +async fn test_authorized_transfer_via_native_fiat_token() -> Result<()> { + reth_tracing::init_test_tracing(); + + let transfer_recipient = address!("0x000000000000000000000000000000000000D00D"); + let mint_amount = U256::from(2_000_000u64); // 2 USDC + let transfer_amount = U256::from(1_000_000u64); // 1 USDC + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let operator = node.wallet_signer(WALLET_OPERATOR_INDEX)?; + let mint_calldata = INativeFiatToken::mintCall { + to: operator.address(), + amount: mint_amount, + } + .abi_encode(); + + let transfer_calldata = INativeFiatToken::transferCall { + to: transfer_recipient, + amount: transfer_amount, + } + .abi_encode(); + + let mint_receipt = send_and_mine( + &mut node, + operator.clone(), + TransactionRequest { + from: Some(operator.address()), + to: Some(TxKind::Call(NATIVE_FIAT_TOKEN_ADDRESS)), + value: Some(U256::ZERO), + input: TransactionInput::new(Bytes::from(mint_calldata)), + gas: Some(500_000), + ..Default::default() + }, + ) + .await?; + assert!(mint_receipt.status()); + + let transfer_receipt = send_and_mine( + &mut node, + operator.clone(), + TransactionRequest { + from: Some(operator.address()), + to: Some(TxKind::Call(NATIVE_FIAT_TOKEN_ADDRESS)), + value: Some(U256::ZERO), + input: TransactionInput::new(Bytes::from(transfer_calldata)), + gas: Some(500_000), + ..Default::default() + }, + ) + .await?; + assert!(transfer_receipt.status()); + let native_transfer_amount = native_units(transfer_amount); + let log = &transfer_receipt.logs()[0]; + let topics = log.topics(); + assert_eq!(topics.len(), 3); + assert_eq!(topics[0], TRANSFER_EVENT_SIGNATURE); + assert_eq!(topics[1], operator.address().into_word()); + assert_eq!(topics[2], transfer_recipient.into_word()); + assert_eq!( + log.data().data.as_ref(), + native_transfer_amount.to_be_bytes::<32>().as_slice() + ); + node.trace_transaction(transfer_receipt.transaction_hash, call_tracer_options()) + .await?; + let balance = node.balance(transfer_recipient, None).await?; + assert_eq!(balance, native_transfer_amount); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/eip7708_zero_address.rs b/crates/execution-e2e/tests/e2e/eip7708_zero_address.rs new file mode 100644 index 00000000..9a1c6bbd --- /dev/null +++ b/crates/execution-e2e/tests/e2e/eip7708_zero_address.rs @@ -0,0 +1,116 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! EIP-7708 zero address e2e tests. +//! +//! Arc custom behavior: value transfers to Address::ZERO are rejected under Zero5. + +use super::helpers::{eip7708::call_tracer_options, utils::send_and_mine}; +use alloy_primitives::{Address, U256}; +use alloy_rpc_types_eth::TransactionRequest; +use arc_execution_config::hardforks::ArcHardfork; +use arc_execution_e2e::{chainspec::localdev_with_hardforks, ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; +use reth_chainspec::ForkCondition; + +/// Test #24: Send value to Address::ZERO under Zero5 — tx reverts. +#[tokio::test] +async fn test_zero_address_value_transfer_reverts() -> Result<()> { + reth_tracing::init_test_tracing(); + + let value = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(Address::ZERO)), + value: Some(value), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #25: Send zero value to Address::ZERO — should succeed (no transfer, no log). +#[tokio::test] +async fn test_zero_address_zero_value_succeeds() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(Address::ZERO)), + value: Some(U256::ZERO), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} + +/// Test #26: Value transfer to Address::ZERO is rejected before Zero5 metadata activation. +#[tokio::test] +async fn test_zero_address_rejected_before_zero5_metadata_activation() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = localdev_with_hardforks(&[ + (ArcHardfork::Zero3, ForkCondition::Block(0)), + (ArcHardfork::Zero4, ForkCondition::Block(0)), + (ArcHardfork::Zero5, ForkCondition::Block(100)), + (ArcHardfork::Zero6, ForkCondition::Block(100)), + ]); + + let value = U256::from(1_000_000); + + let mut node = ArcTestNode::start(ArcSetup::new().with_chain_spec(chain_spec)).await?; + let signer = node.wallet_signer(0)?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(Address::ZERO)), + value: Some(value), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + assert_eq!(receipt.logs().len(), 0); + node.trace_transaction(receipt.transaction_hash, call_tracer_options()) + .await?; + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/evict_unincludable_txs.rs b/crates/execution-e2e/tests/e2e/evict_unincludable_txs.rs new file mode 100644 index 00000000..da378b3d --- /dev/null +++ b/crates/execution-e2e/tests/e2e/evict_unincludable_txs.rs @@ -0,0 +1,84 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloy_primitives::{Address, U256}; +use alloy_rpc_types_eth::TransactionRequest; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; +use reth_transaction_pool::TransactionPool; + +/// The build loop must NOT evict a merely temporarily-invalid tx. +/// +/// A second tx from the same sender becomes unaffordable once the first tx drains +/// the balance. The build loop hits a non-blocklist `InvalidTransaction` +/// (`LackOfFundForMaxFee`), which is skip-only — the tx must stay in the pool +/// across several builds and must NOT land in the invalid tx list. +#[tokio::test] +async fn test_temporarily_invalid_tx_not_evicted() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender = signer.address(); + + // Wallet 0 starts with ~1e24 wei. Drain almost all of it in `drain`, so the + // follow-up `stuck` tx (nonce+1) cannot cover its value + fees afterwards. + let nearly_all = + U256::from(10u64).pow(U256::from(24u64)) - U256::from(10u64).pow(U256::from(20u64)); + + let drain_hash = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(Address::random())), + value: Some(nearly_all), + ..Default::default() + }, + ) + .await?; + + let stuck_hash = node + .send_tx( + signer, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(Address::random())), + value: Some(nearly_all), + ..Default::default() + }, + ) + .await?; + + // First block includes `drain`, leaving `stuck` unaffordable. + node.produce_block().await?; + let drain_receipt = node.get_receipt(drain_hash).await?; + assert!(drain_receipt.status()); + + // Produce several more blocks; `stuck` must remain in the pool throughout + // (kept for retry, not added to the invalid tx list). + for _ in 0..4 { + node.produce_block().await?; + if !node.node.inner.pool.contains(&stuck_hash) { + return Err(eyre::eyre!( + "temporarily-invalid tx {stuck_hash} was evicted from the pool; \ + it should be kept for retry" + )); + } + } + + Ok(()) +} diff --git a/crates/execution-e2e/tests/gas_limit_validation.rs b/crates/execution-e2e/tests/e2e/gas_limit_validation.rs similarity index 87% rename from crates/execution-e2e/tests/gas_limit_validation.rs rename to crates/execution-e2e/tests/e2e/gas_limit_validation.rs index cd674790..31ab9ed0 100644 --- a/crates/execution-e2e/tests/gas_limit_validation.rs +++ b/crates/execution-e2e/tests/e2e/gas_limit_validation.rs @@ -21,38 +21,36 @@ //! conformance (executor.rs apply_pre_execution_changes). use alloy_rpc_types_engine::PayloadStatusEnum; +use alloy_rpc_types_eth::BlockNumberOrTag; use arc_execution_e2e::{ - actions::{build_payload_for_next_block, set_payload_override_and_rehash, submit_payload}, chainspec::{ localdev_with_block_gas_limit, localdev_with_protocol_config_reverts, BlockGasLimitProvider, LOCAL_DEV, }, - ArcEnvironment, ArcSetup, + ArcSetup, ArcTestNode, }; use eyre::Result; +use super::helpers::payload::{forkchoice_state, mutate_payload, next_payload_attributes}; + /// Helper: build a block with the given gas_limit, then submit via Engine API. async fn submit_with_gas_limit(setup: ArcSetup, gas_limit: u64) -> Result { - let mut env = ArcEnvironment::new(); - setup.apply(&mut env).await?; - - let (mut payload, execution_requests, parent_beacon_block_root) = - build_payload_for_next_block(&env).await?; - - let mut payload_override = payload.payload_inner.payload_inner.clone(); - payload_override.gas_limit = gas_limit; - set_payload_override_and_rehash( - &mut payload, - &execution_requests, - parent_beacon_block_root, - payload_override, - )?; - - let status = submit_payload(&env, payload, execution_requests, parent_beacon_block_root) - .await - .expect("submit_payload RPC call should succeed"); - - Ok(status) + let node = ArcTestNode::start(setup).await?; + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let mut payload = node.get_payload(payload_id).await?; + mutate_payload(&mut payload, |payload| { + payload.as_v1_mut().gas_limit = gas_limit; + })?; + + node.new_payload(payload).await } fn assert_valid(status: &PayloadStatusEnum) { diff --git a/crates/execution-e2e/tests/e2e/hardfork_transition.rs b/crates/execution-e2e/tests/e2e/hardfork_transition.rs new file mode 100644 index 00000000..d30ed47b --- /dev/null +++ b/crates/execution-e2e/tests/e2e/hardfork_transition.rs @@ -0,0 +1,117 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Hardfork transition e2e tests for Arc Chain. +//! +//! These tests verify that block production works correctly across +//! hardfork boundaries for Zero4, Zero5, Zero6, Zero7, and Zero8 hardforks. + +use alloy_rpc_types_eth::BlockNumberOrTag; +use arc_execution_config::hardforks::{is_arc_fork_active, ArcHardfork}; +use arc_execution_e2e::{chainspec::localdev_with_hardforks, ArcSetup, ArcTestNode}; +use eyre::Result; +use reth_chainspec::{ChainSpecProvider, EthereumHardfork, EthereumHardforks, ForkCondition}; + +#[tokio::test] +async fn test_hardfork_active_at_genesis() -> Result<()> { + reth_tracing::init_test_tracing(); + + let node = ArcTestNode::start(ArcSetup::new()).await?; + let block = node.get_block(BlockNumberOrTag::Latest).await?; + let chain_spec = node.node.inner.provider().chain_spec(); + + for hardfork in [ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ArcHardfork::Zero8, + ] { + assert!(is_arc_fork_active( + chain_spec.as_ref(), + hardfork, + block.header.number, + block.header.timestamp + )); + } + Ok(()) +} + +/// Test multiple hardfork transitions in sequence. +#[tokio::test] +async fn test_sequential_hardfork_transitions() -> Result<()> { + reth_tracing::init_test_tracing(); + + let hardforks = [ + (ArcHardfork::Zero3, 2), + (ArcHardfork::Zero4, 4), + (ArcHardfork::Zero5, 6), + (ArcHardfork::Zero6, 8), + (ArcHardfork::Zero7, 10), + (ArcHardfork::Zero8, 12), + ]; + let hardfork_conditions = + hardforks.map(|(hardfork, block_number)| (hardfork, ForkCondition::Block(block_number))); + let chain_spec = localdev_with_hardforks(&hardfork_conditions); + + let mut node = ArcTestNode::start(ArcSetup::new().with_chain_spec(chain_spec)).await?; + let chain_spec = node.node.inner.provider().chain_spec(); + + let block = node.get_block(BlockNumberOrTag::Latest).await?; + for (hardfork, _) in hardforks { + assert!(!is_arc_fork_active( + chain_spec.as_ref(), + hardfork, + block.header.number, + block.header.timestamp + )); + } + + for (hardfork, block_number) in hardforks { + node.produce_blocks(2).await?; + let block = node.get_block(BlockNumberOrTag::Latest).await?; + assert_eq!(block.header.number, block_number); + assert!(is_arc_fork_active( + chain_spec.as_ref(), + hardfork, + block.header.number, + block.header.timestamp + )); + } + Ok(()) +} + +/// Test that Osaka (Fusaka) hardfork is active on localdev and blocks produce correctly. +/// +/// Osaka is a timestamp-based Ethereum hardfork that enables EIP-7212 (P256 precompile), +/// EIP-7934 (RLP block size limit), and other Fusaka EIPs. +#[tokio::test] +async fn test_osaka_active_on_localdev() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let block = node.get_block(BlockNumberOrTag::Latest).await?; + let chain_spec = node.node.inner.provider().chain_spec(); + assert!(chain_spec + .is_ethereum_fork_active_at_timestamp(EthereumHardfork::Osaka, block.header.timestamp)); + node.produce_blocks(3).await?; + let block = node.get_block(BlockNumberOrTag::Latest).await?; + assert_eq!(block.header.number, 3); + assert!(chain_spec + .is_ethereum_fork_active_at_timestamp(EthereumHardfork::Osaka, block.header.timestamp)); + Ok(()) +} diff --git a/crates/execution-e2e/tests/helpers/contracts.rs b/crates/execution-e2e/tests/e2e/helpers/contracts.rs similarity index 91% rename from crates/execution-e2e/tests/helpers/contracts.rs rename to crates/execution-e2e/tests/e2e/helpers/contracts.rs index 19b8fd74..6977877d 100644 --- a/crates/execution-e2e/tests/helpers/contracts.rs +++ b/crates/execution-e2e/tests/e2e/helpers/contracts.rs @@ -15,9 +15,6 @@ // limitations under the License. //! Inline bytecode for minimal test contracts. -//! -//! Each test binary only uses a subset of these; unused items are expected. -#![allow(dead_code)] use alloy_primitives::{keccak256, Address, Bytes}; use revm_bytecode::opcode::*; @@ -87,6 +84,32 @@ pub fn selfdestruct_contract_deploy_code() -> Bytes { deploy_code(&runtime) } +/// Contract that warms a calldata target with BALANCE, then calls SELFDESTRUCT +/// with the same target. +/// +/// ```eas +/// push1 0x00 ;; [0] - calldata offset +/// calldataload ;; [cd[0..32]] - load 32-byte word +/// push1 0x60 ;; [96, cd] - shift amount (256 - 160) +/// shr ;; [addr] - isolate 20-byte address +/// dup1 ;; [addr, addr] - keep addr for SELFDESTRUCT +/// balance ;; [addr, bal] - warm addr by reading its balance +/// pop ;; [addr] - discard balance +/// selfdestruct ;; [] - send balance to warmed addr and destroy +/// ``` +pub fn balance_warming_selfdestruct_contract_deploy_code() -> Bytes { + #[rustfmt::skip] + let runtime = [ + PUSH1, 0x00, CALLDATALOAD, // [cd[0..32]] + PUSH1, 0x60, SHR, // [addr] + DUP1, // [addr, addr] + BALANCE, // [addr, balance] + POP, // [addr] + SELFDESTRUCT, // [] + ]; + deploy_code(&runtime) +} + /// Contract that forwards received value to a target address via CALL. /// /// Pseudocode: `call(gas(), calldata[0:20], callvalue(), 0, 0, 0, 0)` @@ -198,7 +221,7 @@ pub fn call_target_with_value_contract_deploy_code() -> Bytes { /// gas ;; [gas after, gas before] /// swap1 ;; [gas before, gas after] /// sub ;; [gas cost] -/// push1 0x01 ;; [0, gas cost] +/// push1 0x01 ;; [1, gas cost] /// sstore ;; [] - sstore(1, gas cost) /// /// ``` diff --git a/crates/execution-e2e/tests/e2e/helpers/eip7708.rs b/crates/execution-e2e/tests/e2e/helpers/eip7708.rs new file mode 100644 index 00000000..ba053f4c --- /dev/null +++ b/crates/execution-e2e/tests/e2e/helpers/eip7708.rs @@ -0,0 +1,72 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Shared EIP-7708 test constants and helpers. + +use alloy_primitives::{address, Address, B256, U256}; +use alloy_rpc_types_eth::TransactionReceipt; +use alloy_rpc_types_trace::geth::{ + GethDebugBuiltInTracerType, GethDebugTracerConfig, GethDebugTracerType, GethDebugTracingOptions, +}; +use alloy_sol_types::{sol, SolEvent}; + +sol! { + event Transfer(address indexed from, address indexed to, uint256 value); + event NativeCoinTransferred(address indexed from, address indexed to, uint256 amount); +} + +/// ERC-20 Transfer event signature. +pub const TRANSFER_EVENT_SIGNATURE: B256 = Transfer::SIGNATURE_HASH; + +/// EIP-7708 system address — emitter of Transfer logs under Zero5. +pub const SYSTEM_ADDRESS: Address = address!("0xfffffffffffffffffffffffffffffffffffffffe"); + +/// NativeCoinAuthority precompile — emitter of NativeCoinTransferred logs before Zero5. +pub const NATIVE_COIN_AUTHORITY_ADDRESS: Address = + address!("0x1800000000000000000000000000000000000000"); + +pub fn call_tracer_options() -> GethDebugTracingOptions { + GethDebugTracingOptions { + tracer: Some(GethDebugTracerType::BuiltInTracer( + GethDebugBuiltInTracerType::CallTracer, + )), + tracer_config: GethDebugTracerConfig( + serde_json::json!({ "withLog": true, "onlyTopCall": false }), + ), + ..Default::default() + } +} + +/// Asserts `receipt` carries an EIP-7708 Transfer log at `index` from `from` to `to` for `value`. +pub fn assert_transfer_log( + receipt: &TransactionReceipt, + index: usize, + from: Address, + to: Address, + value: U256, +) { + let log = &receipt.logs()[index]; + assert_eq!(log.address(), SYSTEM_ADDRESS); + let topics = log.topics(); + assert_eq!(topics.len(), 3); + assert_eq!(topics[0], TRANSFER_EVENT_SIGNATURE); + assert_eq!(topics[1], from.into_word()); + assert_eq!(topics[2], to.into_word()); + assert_eq!( + log.data().data.as_ref(), + value.to_be_bytes::<32>().as_slice() + ); +} diff --git a/crates/execution-e2e/tests/helpers/mod.rs b/crates/execution-e2e/tests/e2e/helpers/mod.rs similarity index 93% rename from crates/execution-e2e/tests/helpers/mod.rs rename to crates/execution-e2e/tests/e2e/helpers/mod.rs index e7fce071..26cf0555 100644 --- a/crates/execution-e2e/tests/helpers/mod.rs +++ b/crates/execution-e2e/tests/e2e/helpers/mod.rs @@ -14,5 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -pub mod constants; pub mod contracts; +pub mod eip7708; +pub mod payload; +pub mod utils; diff --git a/crates/execution-e2e/tests/e2e/helpers/payload.rs b/crates/execution-e2e/tests/e2e/helpers/payload.rs new file mode 100644 index 00000000..ea7da3fd --- /dev/null +++ b/crates/execution-e2e/tests/e2e/helpers/payload.rs @@ -0,0 +1,55 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloy_primitives::B256; +use alloy_rpc_types_engine::{ExecutionData, ExecutionPayload, ForkchoiceState, PayloadAttributes}; +use arc_execution_e2e::DEFAULT_FEE_RECIPIENT; + +/// Builds default payload attributes for the next block. +pub fn next_payload_attributes(parent_timestamp: u64) -> PayloadAttributes { + PayloadAttributes { + timestamp: parent_timestamp + 1, + prev_randao: B256::random(), + suggested_fee_recipient: DEFAULT_FEE_RECIPIENT, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, + } +} + +/// Builds a forkchoice state where head, safe, and finalized point to the same block. +pub fn forkchoice_state(block_hash: B256) -> ForkchoiceState { + ForkchoiceState { + head_block_hash: block_hash, + safe_block_hash: block_hash, + finalized_block_hash: block_hash, + } +} + +/// Mutates the execution payload and recomputes the block hash. +pub fn mutate_payload( + data: &mut ExecutionData, + mutate: impl FnOnce(&mut ExecutionPayload), +) -> eyre::Result<()> { + mutate(&mut data.payload); + rehash_payload(data) +} + +fn rehash_payload(data: &mut ExecutionData) -> eyre::Result<()> { + let block_hash = data.clone().into_block_raw()?.hash_slow(); + data.payload.as_v1_mut().block_hash = block_hash; + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/helpers/utils.rs b/crates/execution-e2e/tests/e2e/helpers/utils.rs new file mode 100644 index 00000000..67a7b058 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/helpers/utils.rs @@ -0,0 +1,67 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloy_primitives::{Address, Bytes, U256}; +use alloy_rpc_types_eth::{TransactionInput, TransactionReceipt, TransactionRequest}; +use alloy_signer_local::PrivateKeySigner; +use arc_execution_e2e::{ArcTestNode, TxKind}; +use eyre::Result; + +pub fn fee(receipt: &TransactionReceipt) -> U256 { + U256::from(receipt.gas_used) * U256::from(receipt.effective_gas_price) +} + +/// Sends a signed transaction, produces one block, and returns its receipt. +pub async fn send_and_mine( + node: &mut ArcTestNode, + signer: PrivateKeySigner, + request: TransactionRequest, +) -> Result { + let tx_hash = node.send_tx(signer, request).await?; + node.produce_block().await?; + node.get_receipt(tx_hash).await +} + +/// Deploys `bytecode` via CREATE, mines it, and returns the created address and receipt. +/// +/// Panics if the deploy transaction reverts or the receipt omits the contract address. +pub async fn deploy_and_mine( + node: &mut ArcTestNode, + signer: PrivateKeySigner, + bytecode: Bytes, + value: U256, + gas: u64, +) -> Result<(Address, TransactionReceipt)> { + let from = signer.address(); + let receipt = send_and_mine( + node, + signer, + TransactionRequest { + from: Some(from), + to: Some(TxKind::Create), + value: Some(value), + gas: Some(gas), + input: TransactionInput::new(bytecode), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + let address = receipt + .contract_address + .expect("successful CREATE receipt must include contract address"); + Ok((address, receipt)) +} diff --git a/crates/execution-e2e/tests/e2e/invalid_tx_list.rs b/crates/execution-e2e/tests/e2e/invalid_tx_list.rs new file mode 100644 index 00000000..18eb2985 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/invalid_tx_list.rs @@ -0,0 +1,260 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! E2E tests for the invalid_tx_list functionality. +//! +//! The invalid_tx_list is an LRU cache that stores transaction hashes of transactions that +//! caused payload builder failures. This enables fast rejection during validation pre-check +//! to avoid repeatedly attempting to build blocks with problematic transactions. +//! +//! Key behavior: +//! - Unprocessable transactions (wrapped as `UnprocessableTransactionError`) are added to the cache +//! - When the payload builder panics, all pending transactions are added to the cache +//! - Cached transactions are rejected during validation pre-check with InvalidTxError +//! - LRU eviction removes oldest entries when capacity is exceeded +//! +//! Test coverage: +//! - Basic functionality: cache miss allows validation +//! - Disabled invalid_tx_list falls through to full validation +//! - Payload builder panic populates cache and resubmission is rejected + +use alloy_primitives::{address, TxHash}; +use alloy_rpc_types_eth::TransactionRequest; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use arc_execution_txpool::InvalidTxListConfig; +use arc_precompiles::precompile_provider::PANIC_PRECOMPILE_ADDRESS; +use eyre::Result; +use jsonrpsee::core::client::Error as RpcClientError; +use reth_transaction_pool::TransactionPool; +use rstest::rstest; + +const NORMAL_TX_RECIPIENT: alloy_primitives::Address = + address!("0x000000000000000000000000000000000000bEEF"); + +/// Verifies that transactions not in the invalid_tx_list go through full validation +/// and are included in blocks across different configurations: +/// - Enabled with small/large capacity +/// - Disabled (falls through to full validation) +/// - Multiple independent transactions in a single block +#[rstest] +#[case::enabled(true, 1000, 1)] +#[case::disabled(false, 0, 1)] +#[case::large_capacity(true, 100_000, 1)] +#[case::multiple_txs(true, 1000, 3)] +#[tokio::test] +async fn test_normal_tx_processing( + #[case] enabled: bool, + #[case] capacity: u32, + #[case] num_txs: usize, +) -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start( + ArcSetup::new().with_invalid_tx_list_config(InvalidTxListConfig { enabled, capacity }), + ) + .await?; + let signer = node.wallet_signer(0)?; + + let mut txs = Vec::with_capacity(num_txs); + for _ in 0..num_txs { + txs.push( + node.send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NORMAL_TX_RECIPIENT)), + ..Default::default() + }, + ) + .await?, + ); + } + + node.produce_block().await?; + + for tx in txs { + let receipt = node.get_receipt(tx).await?; + assert!(receipt.status()); + } + + Ok(()) +} + +/// Payload builder panic populates invalid tx list and resubmission is rejected. +/// +/// Replicates the production flow when a single transaction causes a panic during execution: +/// 1. Submit two transactions: one valid transfer, one targeting a panicking precompile +/// 2. Trigger payload building - the per-transaction `catch_unwind` (payload.rs:589-622) +/// catches the panic and wraps it as `UnprocessableTransactionError`. Only the +/// offending transaction is purged from the pool and added to the invalid_tx_list. +/// The valid transaction remains includable. +/// 3. Resubmit the panicking transaction — rejected by `eth_sendRawTransaction`. +#[tokio::test] +async fn test_payload_builder_panic_populates_invalid_tx_list() -> Result<()> { + use alloy_primitives::U256; + + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new().with_invalid_tx_list_config( + InvalidTxListConfig { + enabled: true, + capacity: 1000, + }, + )) + .await?; + let signer = node.wallet_signer(0)?; + // Step 1: Submit two transactions, one valid, and one targeting the panicking precompile + let good_tx_hash = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NORMAL_TX_RECIPIENT)), + ..Default::default() + }, + ) + .await?; + + // Keep the signed transaction so the test can resubmit it later and + // inspect the raw txpool rejection. + let panicking_tx_signed = node + .sign_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(PANIC_PRECOMPILE_ADDRESS)), + value: Some(U256::ZERO), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + let panicking_tx_hash = *panicking_tx_signed.tx_hash(); + node.send_signed_tx(panicking_tx_signed.clone()).await?; + + // Step 2: The panicking tx is quarantined. The first build may either fail after + // quarantine or succeed with the valid tx, so only assert the stable invariant here. + let first_produce_result = node.produce_block().await; + let pool = &node.node.inner.pool; + assert!( + !pool.contains(&panicking_tx_hash), + "Panicking tx should be removed from the pool" + ); + + if first_produce_result.is_err() { + let pool = &node.node.inner.pool; + assert!( + pool.contains(&good_tx_hash), + "Good tx should remain in the pool when the first build fails" + ); + node.produce_block().await?; + } + + let receipt = node.get_receipt(good_tx_hash).await?; + assert!(receipt.status()); + let pool = &node.node.inner.pool; + assert!( + !pool.contains(&good_tx_hash), + "Good tx should be removed from the pool after inclusion" + ); + assert!( + !pool.contains(&panicking_tx_hash), + "Panicking tx should remain removed from the pool" + ); + + // Step 3: Resubmit the panicking transaction — should be rejected by invalid_tx_list. + expect_invalid_tx_list_rejection( + node.send_signed_tx(panicking_tx_signed).await, + panicking_tx_hash, + "expected rejection", + ) +} + +/// Default-on regression: with no explicit `with_invalid_tx_list_config`, the +/// `InvalidTxListConfig::default()` must still quarantine a tx that triggers +/// `UnprocessableTransactionError` on resubmission. +#[tokio::test] +async fn test_invalid_tx_list_default_on_quarantines_panicking_tx() -> Result<()> { + use alloy_primitives::U256; + + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + node.send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(NORMAL_TX_RECIPIENT)), + ..Default::default() + }, + ) + .await?; + + let panicking_tx_signed = node + .sign_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(PANIC_PRECOMPILE_ADDRESS)), + value: Some(U256::ZERO), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + let panicking_tx_hash = *panicking_tx_signed.tx_hash(); + node.send_signed_tx(panicking_tx_signed.clone()).await?; + + let _ = node.produce_block().await; + + expect_invalid_tx_list_rejection( + node.send_signed_tx(panicking_tx_signed).await, + panicking_tx_hash, + "expected rejection under default config", + ) +} + +fn expect_invalid_tx_list_rejection( + result: eyre::Result, + tx_hash: TxHash, + context: &str, +) -> Result<()> { + match result { + Ok(_) => Err(eyre::eyre!( + "Transaction {tx_hash} accepted on resubmission, {context}" + )), + Err(err) => { + let rpc_err = err.downcast_ref::().ok_or_else(|| { + eyre::eyre!("Transaction {tx_hash} rejected outside JSON-RPC path: {err:?}") + })?; + + let RpcClientError::Call(call_err) = rpc_err else { + return Err(eyre::eyre!( + "Transaction {tx_hash} rejected with unexpected RPC error: {rpc_err:?}" + )); + }; + assert!( + call_err + .message() + .to_lowercase() + .contains("invalid tx list"), + "Expected invalid tx list rejection in RPC error, got: {call_err:?}" + ); + Ok(()) + } + } +} diff --git a/crates/execution-e2e/tests/e2e/main.rs b/crates/execution-e2e/tests/e2e/main.rs new file mode 100644 index 00000000..7a86c0fb --- /dev/null +++ b/crates/execution-e2e/tests/e2e/main.rs @@ -0,0 +1,45 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![allow(clippy::arithmetic_side_effects)] + +mod base_fee; +mod beneficiary_blocklist; +mod block_hash_history; +mod block_production; +mod denylist; +mod eip161_empty_account_clearing; +mod eip7708_denylist; +mod eip7708_edge_cases; +mod eip7708_hardfork_transition; +mod eip7708_log_format; +mod eip7708_native_transfer; +mod eip7708_payload_validation; +mod eip7708_precompile; +mod eip7708_zero_address; +mod evict_unincludable_txs; +mod gas_limit_validation; +mod hardfork_transition; +mod helpers; +mod invalid_tx_list; +mod native_transfer_balance; +mod p256_precompile; +mod pq_precompile; +mod selfdestruct_beneficiary; +mod sparse_trie_payload_state_root; +mod static_rpc_gas_cap; +mod transaction; +mod withdrawals_payload_validation; diff --git a/crates/execution-e2e/tests/e2e/native_transfer_balance.rs b/crates/execution-e2e/tests/e2e/native_transfer_balance.rs new file mode 100644 index 00000000..7f9dedd3 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/native_transfer_balance.rs @@ -0,0 +1,162 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] + +//! E2E tests verifying native value transfer balance changes. + +use super::helpers::utils::{fee, send_and_mine}; +use alloy_primitives::{address, Address, U256}; +use alloy_rpc_types_eth::TransactionRequest; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; + +/// Recipient with zero balance in genesis. +const RECIPIENT: Address = address!("0x000000000000000000000000000000000000bEEF"); + +/// Transfer value used in tests: 100 USDC (100e18 wei). +fn transfer_value() -> U256 { + U256::from(100u64) * U256::from(10u64).pow(U256::from(18u64)) +} + +/// Recipient balance goes from 0 to the transferred value. +#[tokio::test] +async fn test_value_transfer_credits_recipient() -> Result<()> { + reth_tracing::init_test_tracing(); + + let value = transfer_value(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender_before = node.balance(signer.address(), None).await?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(RECIPIENT)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + let balance = node.balance(RECIPIENT, None).await?; + assert_eq!(balance, value); + let sender_after = node.balance(signer.address(), None).await?; + assert_eq!(sender_after, sender_before - value - fee(&receipt)); + Ok(()) +} + +/// Sender balance decreases by at least the transferred value (plus gas). +#[tokio::test] +async fn test_value_transfer_debits_sender() -> Result<()> { + reth_tracing::init_test_tracing(); + + let value = transfer_value(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let before = node.balance(signer.address(), None).await?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(RECIPIENT)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + let after = node.balance(signer.address(), None).await?; + assert_eq!(after, before - value - fee(&receipt)); + Ok(()) +} + +/// Zero-value transfer leaves recipient balance unchanged. +#[tokio::test] +async fn test_zero_value_transfer_no_balance_change() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let before = node.balance(RECIPIENT, None).await?; + let sender_before = node.balance(signer.address(), None).await?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(RECIPIENT)), + value: Some(U256::ZERO), + ..Default::default() + }, + ) + .await?; + assert!(receipt.status()); + let after = node.balance(RECIPIENT, None).await?; + assert_eq!(after, before); + let sender_after = node.balance(signer.address(), None).await?; + assert_eq!(sender_after, sender_before - fee(&receipt)); + Ok(()) +} + +/// Two transfers to the same recipient accumulate. +#[tokio::test] +async fn test_multiple_transfers_accumulate() -> Result<()> { + reth_tracing::init_test_tracing(); + + let value = transfer_value(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let sender_before = node.balance(signer.address(), None).await?; + let tx1 = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(RECIPIENT)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + let tx2 = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(RECIPIENT)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + node.produce_block().await?; + let receipt1 = node.get_receipt(tx1).await?; + let receipt2 = node.get_receipt(tx2).await?; + assert!(receipt1.status()); + assert!(receipt2.status()); + let balance = node.balance(RECIPIENT, None).await?; + assert_eq!(balance, value + value); + let sender_after = node.balance(signer.address(), None).await?; + let total_fee = fee(&receipt1) + fee(&receipt2); + assert_eq!(sender_after, sender_before - (value + value + total_fee)); + Ok(()) +} diff --git a/crates/execution-e2e/tests/p256_precompile.rs b/crates/execution-e2e/tests/e2e/p256_precompile.rs similarity index 88% rename from crates/execution-e2e/tests/p256_precompile.rs rename to crates/execution-e2e/tests/e2e/p256_precompile.rs index e9285360..f9db6e8e 100644 --- a/crates/execution-e2e/tests/p256_precompile.rs +++ b/crates/execution-e2e/tests/e2e/p256_precompile.rs @@ -23,10 +23,8 @@ //! commonly used for passkey authentication. use alloy_primitives::{address, bytes, Address, Bytes}; -use arc_execution_e2e::{ - actions::{CallContract, ProduceBlocks}, - ArcSetup, ArcTestBuilder, -}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; use eyre::Result; use rstest::rstest; @@ -81,15 +79,16 @@ async fn test_p256_verification( ) -> Result<()> { reth_tracing::init_test_tracing(); - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action(ProduceBlocks::new(1)) - .with_action( - CallContract::new(label) - .to(P256_PRECOMPILE_ADDRESS) - .with_data(input) - .expect_result(expected), - ) - .run() + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + node.produce_block().await?; + let output = node + .call(TransactionRequest { + to: Some(TxKind::Call(P256_PRECOMPILE_ADDRESS)), + input: TransactionInput::new(input), + ..Default::default() + }) .await + .map_err(|err| eyre::eyre!("{label}: {err}"))?; + assert_eq!(output, expected, "{label}"); + Ok(()) } diff --git a/crates/execution-e2e/tests/pq_precompile.rs b/crates/execution-e2e/tests/e2e/pq_precompile.rs similarity index 74% rename from crates/execution-e2e/tests/pq_precompile.rs rename to crates/execution-e2e/tests/e2e/pq_precompile.rs index a27121ec..e2eed678 100644 --- a/crates/execution-e2e/tests/pq_precompile.rs +++ b/crates/execution-e2e/tests/e2e/pq_precompile.rs @@ -27,11 +27,9 @@ //! LOCAL_DEV, where Zero6 is active from genesis—same scope as `p256_precompile.rs`. use alloy_primitives::{bytes, Bytes}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; use alloy_sol_types::SolCall; -use arc_execution_e2e::{ - actions::{CallContract, ProduceBlocks}, - ArcSetup, ArcTestBuilder, -}; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; use arc_precompiles::{ pq::{IPQ, PQ_ADDRESS}, pq_test_vectors::{self, MSG_EMPTY, MSG_GOODBYE_WORLD, MSG_HELLO_WORLD}, @@ -162,23 +160,48 @@ async fn test_pq_precompile(#[case] index: usize) -> Result<()> { .abi_encode() .into(); - let call = CallContract::new(vector.call_label) - .to(PQ_ADDRESS) - .with_data(data); - let call = match vector.expected { - PqExpected::ReturnTrue => call.expect_result(RETURN_TRUE), - PqExpected::ReturnFalse => call.expect_result(RETURN_FALSE), - PqExpected::Revert => call.expect_revert(), - }; + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + // Advance one block after genesis — the usual e2e harness step so the mock node has a + // progressed head before `eth_call`. This is not waiting on a hardfork height: default + // `ArcSetup` uses LOCAL_DEV, where Arc forks (including Zero6 / PQ) are active at block 0 + // (`ARC_LOCALDEV_HARDFORKS` in `execution-config`). + node.produce_block().await?; - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Advance one block after genesis — the usual e2e harness step so the mock node has a - // progressed head before `eth_call`. This is not waiting on a hardfork height: default - // `ArcSetup` uses LOCAL_DEV, where Arc forks (including Zero6 / PQ) are active at block 0 - // (`ARC_LOCALDEV_HARDFORKS` in `execution-config`). - .with_action(ProduceBlocks::new(1)) - .with_action(call) - .run() - .await + match vector.expected { + PqExpected::ReturnTrue => { + let output = node + .call(TransactionRequest { + to: Some(TxKind::Call(PQ_ADDRESS)), + input: TransactionInput::new(data), + ..Default::default() + }) + .await?; + assert_eq!(output, RETURN_TRUE, "{}", vector.call_label); + } + PqExpected::ReturnFalse => { + let output = node + .call(TransactionRequest { + to: Some(TxKind::Call(PQ_ADDRESS)), + input: TransactionInput::new(data), + ..Default::default() + }) + .await?; + assert_eq!(output, RETURN_FALSE, "{}", vector.call_label); + } + PqExpected::Revert => { + let result = node + .call(TransactionRequest { + to: Some(TxKind::Call(PQ_ADDRESS)), + input: TransactionInput::new(data), + ..Default::default() + }) + .await; + assert!( + result.is_err(), + "{} expected eth_call to revert", + vector.call_label + ); + } + } + Ok(()) } diff --git a/crates/execution-e2e/tests/e2e/selfdestruct_beneficiary.rs b/crates/execution-e2e/tests/e2e/selfdestruct_beneficiary.rs new file mode 100644 index 00000000..908ccfc4 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/selfdestruct_beneficiary.rs @@ -0,0 +1,210 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! E2E regression for a fee paid to a beneficiary that is created and self-destructed in the +//! same transaction: it must not be silently burned. + +use alloy_primitives::{address, Address, Bytes, TxHash, U256}; +use alloy_rpc_types_engine::{ExecutionData, PayloadStatusEnum}; +use alloy_rpc_types_eth::{BlockId, BlockNumberOrTag, TransactionInput, TransactionRequest}; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; + +use super::helpers::payload::{forkchoice_state, mutate_payload, next_payload_attributes}; + +/// Initcode that self-destructs during construction: `PUSH1 0x00; SELFDESTRUCT`. +/// Under EIP-6780 the account is created and destroyed within the same tx, so revm leaves the +/// self-destruct marker in place through post-execution (when `reward_beneficiary` runs). +const SELFDESTRUCT_IN_CONSTRUCTOR: [u8; 3] = [0x60, 0x00, 0xff]; +const VALID_RECIPIENT: Address = address!("0x1000000000000000000000000000000000000001"); + +async fn submit_selfdestructing_create( + node: &ArcTestNode, + signer_index: usize, +) -> Result<(TxHash, Address)> { + let signer = node.wallet_signer(signer_index)?; + let nonce = node + .nonce( + signer.address(), + Some(BlockId::Number(BlockNumberOrTag::Pending)), + ) + .await?; + let tx_hash = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Create), + value: Some(U256::ZERO), + nonce: Some(nonce), + gas: Some(200_000), + input: TransactionInput::new(Bytes::from(SELFDESTRUCT_IN_CONSTRUCTOR.to_vec())), + ..Default::default() + }, + ) + .await?; + + Ok((tx_hash, signer.address().create(nonce))) +} + +async fn build_payload_with_fee_recipient( + node: &ArcTestNode, + fee_recipient: Address, +) -> Result { + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let mut payload_attributes = next_payload_attributes(parent.header.timestamp); + payload_attributes.suggested_fee_recipient = fee_recipient; + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + + node.get_payload(payload_id).await +} + +fn assert_valid(status: &PayloadStatusEnum, context: &str) -> Result<()> { + match status { + PayloadStatusEnum::Valid => Ok(()), + other => Err(eyre::eyre!( + "{context} returned unexpected status: {other:?}" + )), + } +} + +/// Reproduces the deploy-and-self-destruct-as-fee-recipient scenario end to end. +/// +/// - A CREATE tx self-destructs in its constructor; its deterministic address becomes the block +/// beneficiary (proposer-selected fee recipient). +/// - Under Zero8 (active at localdev genesis) crediting that self-destructed beneficiary is +/// rejected, so the payload must be INVALID rather than silently burning the fee. +#[tokio::test] +async fn test_proposer_selected_selfdestructed_beneficiary_is_invalid() -> Result<()> { + reth_tracing::init_test_tracing(); + + let node = ArcTestNode::start(ArcSetup::new()).await?; + + // Send a deploy-and-self-destruct tx and derive its deterministic CREATE address. + let (_deploy_tx_hash, selfdestructed_beneficiary) = + submit_selfdestructing_create(&node, 0).await?; + + // Build the block with the default (valid) beneficiary so the tx is included. + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let mut payload = node.get_payload(payload_id).await?; + assert_eq!( + payload.transaction_count(), + 1, + "deploy-and-self-destruct tx must be included in the built block" + ); + + // Re-point the proposer-selected fee recipient at the self-destructed contract. + mutate_payload(&mut payload, |payload| { + payload.as_v1_mut().fee_recipient = selfdestructed_beneficiary; + })?; + + let status = node.new_payload(payload).await?; + + assert!( + matches!( + &status, + PayloadStatusEnum::Invalid { validation_error } + if validation_error.to_ascii_lowercase().contains("selfdestructed") + ), + "Expected INVALID with self-destructed-balance validation error, got {:?}", + status + ); + + Ok(()) +} + +/// The local proposer should treat the deploy-and-self-destruct tx as invalid for this payload +/// attempt, skip it, and keep building the block with other independent transactions. +#[tokio::test] +async fn test_payload_builder_skips_selfdestructed_beneficiary_tx_in_multi_tx_block() -> Result<()> +{ + reth_tracing::init_test_tracing(); + + let node = ArcTestNode::start(ArcSetup::new()).await?; + + let (deploy_tx_hash, selfdestructed_beneficiary) = + submit_selfdestructing_create(&node, 0).await?; + let valid_a_signer = node.wallet_signer(1)?; + let valid_a_from = valid_a_signer.address(); + let valid_a_hash = node + .send_tx( + valid_a_signer, + TransactionRequest { + from: Some(valid_a_from), + to: Some(TxKind::Call(VALID_RECIPIENT)), + ..Default::default() + }, + ) + .await?; + let valid_b_signer = node.wallet_signer(2)?; + let valid_b_from = valid_b_signer.address(); + let valid_b_hash = node + .send_tx( + valid_b_signer, + TransactionRequest { + from: Some(valid_b_from), + to: Some(TxKind::Call(VALID_RECIPIENT)), + ..Default::default() + }, + ) + .await?; + + let payload = build_payload_with_fee_recipient(&node, selfdestructed_beneficiary).await?; + assert_eq!( + payload.transaction_count(), + 2, + "payload builder should skip the selfdestructing tx and keep both independent valid txs" + ); + + let status = node.new_payload(payload.clone()).await?; + assert_valid(&status, "newPayload")?; + + let fcu_result = node + .fork_choice_updated(forkchoice_state(payload.block_hash()), None) + .await?; + assert_valid( + &fcu_result.payload_status.status, + "forkChoiceUpdated while finalizing block", + )?; + + let block = node.get_block(BlockNumberOrTag::Number(1)).await?; + assert!( + !block + .transactions + .hashes() + .any(|tx_hash| tx_hash == deploy_tx_hash), + "deploy-and-self-destruct tx should not be included" + ); + assert!(node.get_receipt(deploy_tx_hash).await.is_err()); + assert!(node.get_receipt(valid_a_hash).await?.status()); + assert!(node.get_receipt(valid_b_hash).await?.status()); + + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/sparse_trie_payload_state_root.rs b/crates/execution-e2e/tests/e2e/sparse_trie_payload_state_root.rs new file mode 100644 index 00000000..fea10ba4 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/sparse_trie_payload_state_root.rs @@ -0,0 +1,88 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Regression test for the shared sparse-trie payload-builder state root. +//! +//! With `--engine.share-sparse-trie-with-payload-builder` enabled, the payload +//! builder seals each block with the state root precomputed by the engine's +//! shared sparse-trie task. That root is only correct if every state change the +//! block makes, including Arc's unconditional end-of-block SystemAccounting +//! write performed in `ArcBlockExecutor::finish`, is streamed to the task +//! before the root is fixed. +//! +//! The earlier ordering detached the state hook and resolved the root *before* +//! `executor.finish()` ran, so the SystemAccounting storage delta never reached +//! the task and the sealed header carried an incomplete root. `newPayload` +//! re-executes with the hook attached, recomputes the complete root, and +//! rejects the block as `mismatched block state root`, which crash-looped +//! every proposer at height 1. +//! +//! `ArcTestNode::produce_block` drives FCU -> getPayload -> newPayload -> FCU +//! and asserts `newPayload` is valid. So producing blocks with the flag ON is itself the +//! equality check between the build-time root and the re-execution root: the +//! action only succeeds when they match. This test exercises that path on an +//! empty block (the exact `./quake start` failure shape), on a block carrying a +//! transaction, and across several heights (anchored-trie reuse). + +use alloy_primitives::{address, Address, U256}; +use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionRequest}; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; + +const RECIPIENT: Address = address!("0x000000000000000000000000000000000000bEEF"); + +#[tokio::test] +async fn sparse_trie_payload_builds_blocks_with_matching_state_root() -> Result<()> { + reth_tracing::init_test_tracing(); + + let value = U256::from(100u64) * U256::from(10u64).pow(U256::from(18u64)); + + let mut node = ArcTestNode::start(ArcSetup::new().with_share_sparse_trie(true)).await?; + let signer = node.wallet_signer(0)?; + + // Empty block 1: the exact shape that crash-looped `./quake start`. + // Even with no transactions, `ArcBlockExecutor::finish` writes the + // per-block SystemAccounting gas-accounting slot, so the build-time + // root must include it for `newPayload` to accept the block. + node.produce_block().await?; + let block = node.get_block(BlockNumberOrTag::Latest).await?; + assert_eq!(block.header.number, 1); + + // A block carrying a transfer: both the transfer and the per-block + // SystemAccounting write must be reflected in the shared-trie root. + let tx_hash = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(RECIPIENT)), + value: Some(value), + ..Default::default() + }, + ) + .await?; + node.produce_block().await?; + let receipt = node.get_receipt(tx_hash).await?; + assert!(receipt.status()); + assert_eq!(node.balance(RECIPIENT, None).await?, value); + + // Several more heights to exercise anchored-trie reuse across blocks. + node.produce_blocks(3).await?; + let block = node.get_block(BlockNumberOrTag::Latest).await?; + assert_eq!(block.header.number, 5); + + Ok(()) +} diff --git a/crates/execution-e2e/tests/static_rpc_gas_cap.rs b/crates/execution-e2e/tests/e2e/static_rpc_gas_cap.rs similarity index 52% rename from crates/execution-e2e/tests/static_rpc_gas_cap.rs rename to crates/execution-e2e/tests/e2e/static_rpc_gas_cap.rs index 7775937e..005da2ed 100644 --- a/crates/execution-e2e/tests/static_rpc_gas_cap.rs +++ b/crates/execution-e2e/tests/e2e/static_rpc_gas_cap.rs @@ -32,16 +32,11 @@ //! clamped; the burner runs to completion and the call returns Ok. Test //! fails — that is the registration-direction signal. -use alloy_primitives::{Address, Bytes, TxKind}; +use alloy_primitives::{Bytes, TxKind}; use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; use arc_execution_config::chainspec::localdev_with_block_gas_limit; -use arc_execution_e2e::{ - actions::{ProduceBlocks, SendTransaction, StoreDeployedAddress}, - Action, ArcEnvironment, ArcSetup, ArcTestBuilder, -}; +use arc_execution_e2e::{ArcSetup, ArcTestNode}; use eyre::Result; -use futures_util::future::BoxFuture; -use reth_rpc_api::EthApiClient; /// Block gas limit for the test. Set above the static cap so the cap is the /// binding constraint on `eth_call`. @@ -97,72 +92,6 @@ fn gas_burner_bytecode() -> Bytes { .into() } -/// Reads the named deployed address from the environment, issues `eth_call` -/// against it with explicit `gas`, and asserts the call fails out-of-gas. -/// With `--rpc.gascap=30M` Reth clamps the request to 30M and the burner OOGs. -/// Without the cap, the call retains its requested gas and the burner runs to -/// completion — that path makes this test fail, which is the -/// patch-direction signal. -struct AssertCallOutOfGas { - address_name: String, - gas: u64, -} - -impl Action for AssertCallOutOfGas { - fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, Result<()>> { - Box::pin(async move { - let to: Address = *env - .get_address(&self.address_name) - .ok_or_else(|| eyre::eyre!("named address '{}' not stored", self.address_name))?; - let client = env - .node() - .rpc_client() - .ok_or_else(|| eyre::eyre!("RPC client not available"))?; - - let request = TransactionRequest { - to: Some(TxKind::Call(to)), - gas: Some(self.gas), - input: TransactionInput::default(), - ..Default::default() - }; - - let result = >::call(&client, request, None, None, None) - .await; - - match result { - Ok(output) => Err(eyre::eyre!( - "eth_call against gas burner with gas={} succeeded (output: {output}); \ - Reth did not clamp to --rpc.gascap. Is the Arc default in place?", - self.gas - )), - Err(err) => { - // Match revm's `OutOfGas(...)` family. Both the Debug - // (`OutOfGas`) and Display (`out of gas`) renderings are - // covered after `to_lowercase()`. Other "gas" substrings - // (e.g. "intrinsic gas too low", "max fee per gas") are - // intentionally not accepted: a non-OOG failure should - // surface, not silently pass. - let msg = err.to_string().to_lowercase(); - if msg.contains("out of gas") || msg.contains("outofgas") { - Ok(()) - } else { - Err(eyre::eyre!( - "eth_call failed with non-OOG error (expected out-of-gas): {err}" - )) - } - } - } - }) - } -} - /// One launched-node test: with `--rpc.gascap=30M` and `block.gas_limit=50M`, /// `eth_call(gas=40M)` against a ~35M gas burner returns out-of-gas. The /// cap is the binding constraint; removing it (Reth's stock 50M default) @@ -171,24 +100,58 @@ impl Action for AssertCallOutOfGas { async fn static_rpc_gas_cap_clamps_eth_call() -> Result<()> { reth_tracing::init_test_tracing(); - ArcTestBuilder::new() - .with_setup( - ArcSetup::new() - .with_chain_spec(localdev_with_block_gas_limit(TEST_BLOCK_GAS_LIMIT)) - .with_rpc_gas_cap(STATIC_GAS_CAP), - ) - .with_action( - SendTransaction::new("deploy_burner") - .with_create() - .with_data(gas_burner_bytecode()) - .with_gas_limit(200_000), + let mut node = ArcTestNode::start( + ArcSetup::new() + .with_chain_spec(localdev_with_block_gas_limit(TEST_BLOCK_GAS_LIMIT)) + .with_rpc_gas_cap(STATIC_GAS_CAP), + ) + .await?; + let signer = node.wallet_signer(0)?; + let deploy_tx = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Create), + gas: Some(200_000), + input: TransactionInput::new(gas_burner_bytecode()), + ..Default::default() + }, ) - .with_action(ProduceBlocks::new(1)) - .with_action(StoreDeployedAddress::new("deploy_burner")) - .with_action(AssertCallOutOfGas { - address_name: "deploy_burner_address".to_string(), - gas: REQUESTED_CALL_GAS, - }) - .run() - .await + .await?; + node.produce_block().await?; + let deploy_receipt = node.get_receipt(deploy_tx).await?; + assert!(deploy_receipt.status()); + let burner = deploy_receipt + .contract_address + .expect("gas burner deploy receipt must include contract address"); + + let request = TransactionRequest { + to: Some(TxKind::Call(burner)), + gas: Some(REQUESTED_CALL_GAS), + input: TransactionInput::default(), + ..Default::default() + }; + + let result = node.call(request).await; + + match result { + Ok(output) => Err(eyre::eyre!( + "eth_call against gas burner with gas={REQUESTED_CALL_GAS} succeeded \ + (output: {output}); Reth did not clamp to --rpc.gascap. \ + Is the Arc default in place?" + )), + Err(err) => { + // Match revm's `OutOfGas(...)` family. Other gas-related errors + // should surface rather than silently passing this differential test. + let msg = err.to_string().to_lowercase(); + if msg.contains("out of gas") || msg.contains("outofgas") { + Ok(()) + } else { + Err(eyre::eyre!( + "eth_call failed with non-OOG error (expected out-of-gas): {err}" + )) + } + } + } } diff --git a/crates/execution-e2e/tests/e2e/transaction.rs b/crates/execution-e2e/tests/e2e/transaction.rs new file mode 100644 index 00000000..321f3627 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/transaction.rs @@ -0,0 +1,122 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] + +//! Transaction sending e2e tests for Arc Chain. + +use super::helpers::utils::{fee, send_and_mine}; +use alloy_primitives::{address, bytes, Address, U256}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; +use arc_execution_e2e::{ArcSetup, ArcTestNode, TxKind}; +use eyre::Result; + +/// Recipient with zero balance in genesis. +const RECIPIENT: Address = address!("0x000000000000000000000000000000000000bEEF"); + +/// Test sending multiple transactions in a single block. +#[tokio::test] +async fn test_multiple_transactions() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + + let before = node.balance(signer.address(), None).await?; + + let tx1 = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(RECIPIENT)), + value: Some(U256::from(1u64)), + ..Default::default() + }, + ) + .await?; + let tx2 = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(RECIPIENT)), + value: Some(U256::from(1u64)), + ..Default::default() + }, + ) + .await?; + node.produce_block().await?; + + let tx3 = node + .send_tx( + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(RECIPIENT)), + value: Some(U256::from(1u64)), + ..Default::default() + }, + ) + .await?; + node.produce_block().await?; + + let receipt1 = node.get_receipt(tx1).await?; + let receipt2 = node.get_receipt(tx2).await?; + let receipt3 = node.get_receipt(tx3).await?; + + assert!(receipt1.status()); + assert!(receipt2.status()); + assert!(receipt3.status()); + assert_eq!(receipt1.block_number, Some(1)); + assert_eq!(receipt2.block_number, Some(1)); + assert_eq!(receipt3.block_number, Some(2)); + + let total_fee = fee(&receipt1) + fee(&receipt2) + fee(&receipt3); + let after = node.balance(signer.address(), None).await?; + assert_eq!(after, before - (U256::from(3u64) + total_fee)); + + Ok(()) +} + +/// Test that a contract call that reverts is detected. +#[tokio::test] +async fn test_reverted_transaction() -> Result<()> { + reth_tracing::init_test_tracing(); + + let mut node = ArcTestNode::start(ArcSetup::new()).await?; + let signer = node.wallet_signer(0)?; + let before = node.balance(signer.address(), None).await?; + let receipt = send_and_mine( + &mut node, + signer.clone(), + TransactionRequest { + from: Some(signer.address()), + to: Some(TxKind::Call(address!( + "0x3600000000000000000000000000000000000000" + ))), + value: Some(U256::ZERO), // Value must be 0: FiatTokenProxy is pre-blocklisted in NativeCoinControl. + input: TransactionInput::new(bytes!("0x1234abcd")), + gas: Some(100_000), + ..Default::default() + }, + ) + .await?; + assert!(!receipt.status()); + let after = node.balance(signer.address(), None).await?; + assert_eq!(after, before - fee(&receipt)); + Ok(()) +} diff --git a/crates/execution-e2e/tests/e2e/withdrawals_payload_validation.rs b/crates/execution-e2e/tests/e2e/withdrawals_payload_validation.rs new file mode 100644 index 00000000..42f92986 --- /dev/null +++ b/crates/execution-e2e/tests/e2e/withdrawals_payload_validation.rs @@ -0,0 +1,64 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Withdrawals payload validation e2e tests. +//! +//! Verifies that the Engine API rejects a block proposed by a bad validator +//! with a non-empty withdrawals list, since Arc never applies withdrawals to balances. + +use alloy_rpc_types_engine::PayloadStatusEnum; +use alloy_rpc_types_eth::BlockNumberOrTag; +use arc_execution_e2e::{ArcSetup, ArcTestNode}; +use eyre::Result; + +use super::helpers::payload::{forkchoice_state, mutate_payload, next_payload_attributes}; + +/// A payload with a non-empty withdrawals list is rejected as INVALID via `engine_newPayload`. +#[tokio::test] +async fn test_payload_with_withdrawals_rejected() -> Result<()> { + reth_tracing::init_test_tracing(); + + let node = ArcTestNode::start(ArcSetup::new()).await?; + + let parent = node.get_block(BlockNumberOrTag::Latest).await?; + let fork_choice_state = forkchoice_state(parent.header.hash); + let payload_attributes = next_payload_attributes(parent.header.timestamp); + let fcu_result = node + .fork_choice_updated(fork_choice_state, Some(payload_attributes)) + .await?; + let payload_id = fcu_result + .payload_id + .ok_or_else(|| eyre::eyre!("forkChoiceUpdated did not return a payload ID"))?; + let mut payload = node.get_payload(payload_id).await?; + + mutate_payload(&mut payload, |payload| { + payload + .as_v2_mut() + .expect("expected ExecutionPayloadV2+") + .withdrawals = vec![Default::default()]; + })?; + + let status = node.new_payload(payload).await?; + + match status { + PayloadStatusEnum::Invalid { validation_error } => { + assert_eq!(validation_error, "Withdrawals are not supported"); + } + other => panic!("Expected INVALID status for withdrawals payload, got {other:?}"), + } + + Ok(()) +} diff --git a/crates/execution-e2e/tests/eip7708_denylist.rs b/crates/execution-e2e/tests/eip7708_denylist.rs deleted file mode 100644 index 40e8be37..00000000 --- a/crates/execution-e2e/tests/eip7708_denylist.rs +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! EIP-7708 denylist interaction e2e tests. -//! -//! Verifies that the addresses denylist correctly blocks transactions with value -//! transfers to/from denylisted addresses, and that exclusion lists allow -//! transfers with proper EIP-7708 log emission. - -mod helpers; - -use alloy_network::eip2718::{Decodable2718, Encodable2718}; -use alloy_primitives::{address, Address, TxKind, U256}; -use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; -use arc_execution_config::addresses_denylist::{ - AddressesDenylistConfig, DEFAULT_DENYLIST_ADDRESS, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, -}; -use arc_execution_config::chainspec::ArcChainSpec; -use arc_execution_e2e::{ - actions::{ - AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, SendTransaction, TxStatus, - }, - chainspec::localdev_with_denylisted_addresses, - ArcEnvironment, ArcSetup, ArcTestBuilder, -}; -use arc_execution_txpool::ArcTransactionValidatorError; -use helpers::constants::{SYSTEM_ADDRESS, WALLET_FIRST_ADDRESS}; -use reth_chainspec::EthChainSpec; -use reth_e2e_test_utils::transaction::TransactionTestContext; -use reth_ethereum_primitives::TransactionSigned; -use reth_primitives_traits::SignerRecoverable; -use reth_transaction_pool::error::{PoolError, PoolErrorKind}; -use reth_transaction_pool::{TransactionOrigin, TransactionPool}; -use std::sync::Arc; - -fn denylist_config_enabled(exclusions: Vec
) -> eyre::Result { - Ok(AddressesDenylistConfig::try_new( - true, - Some(DEFAULT_DENYLIST_ADDRESS), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), - exclusions, - )?) -} - -/// Builds a signed tx from a wallet to `to` with a given value. -/// -/// Uses `wallet.inner` directly instead of regenerating signers via `wallet_gen()`. -async fn build_signed_tx_raw( - wallet: &reth_e2e_test_utils::wallet::Wallet, - to: Address, - value: U256, -) -> alloy_primitives::Bytes { - let tx = TransactionRequest { - nonce: Some(0), - value: Some(value), - to: Some(TxKind::Call(to)), - gas: Some(26000), - max_fee_per_gas: Some(1_000_000_000_000), - max_priority_fee_per_gas: Some(1_000_000_000), - chain_id: Some(wallet.chain_id), - input: TransactionInput::default(), - ..Default::default() - }; - let signed_tx = TransactionTestContext::sign_tx(wallet.inner.clone(), tx).await; - signed_tx.encoded_2718().into() -} - -/// Launches a node with denylist config, signs and submits a value transfer tx. -async fn sign_and_submit_value_tx( - chain_spec: Arc, - addresses_denylist_config: AddressesDenylistConfig, - to: Address, - value: U256, -) -> Result<(), eyre::Report> { - let mut env = ArcEnvironment::new(); - ArcSetup::new() - .with_chain_spec(chain_spec.clone()) - .with_addresses_denylist_config(addresses_denylist_config) - .apply(&mut env) - .await?; - - let wallet = - reth_e2e_test_utils::wallet::Wallet::default().with_chain_id(chain_spec.chain().id()); - - let raw_tx = build_signed_tx_raw(&wallet, to, value).await; - let tx_signed = TransactionSigned::decode_2718(&mut raw_tx.as_ref()).expect("Decode tx"); - let recovered_tx = tx_signed.try_into_recovered().expect("Recover signer"); - env.node() - .inner - .pool - .add_consensus_transaction(recovered_tx, TransactionOrigin::Local) - .await - .map_err(Into::into) - .map(|_| ()) -} - -/// Test #27: Value transfer to a denylisted address is rejected by the txpool. -#[tokio::test] -async fn test_value_transfer_to_denylisted_rejected() -> eyre::Result<()> { - reth_tracing::init_test_tracing(); - - let denylisted_to = address!("0xdead000000000000000000000000000000000001"); - let chain_spec = localdev_with_denylisted_addresses(vec![denylisted_to]); - let addresses_denylist_config = denylist_config_enabled(Vec::new())?; - - let err = sign_and_submit_value_tx( - chain_spec, - addresses_denylist_config, - denylisted_to, - U256::from(1_000_000), - ) - .await - .expect_err("Expected pool to reject tx to denylisted address"); - - let pool_err = err.downcast_ref::().expect("Expected PoolError"); - let invalid = match &pool_err.kind { - PoolErrorKind::InvalidTransaction(e) => e, - other => panic!("Expected InvalidTransaction (denylist), got: {:?}", other), - }; - let arc_err = invalid - .downcast_other_ref::() - .expect("Expected ArcTransactionValidatorError"); - assert!( - matches!( - arc_err, - ArcTransactionValidatorError::DenylistedAddressError(_) - ), - "Expected DenylistedAddressError, got: {:?}", - arc_err - ); - - Ok(()) -} - -/// Test #28: Value transfer from a denylisted sender is rejected by the txpool. -#[tokio::test] -async fn test_value_transfer_from_denylisted_rejected() -> eyre::Result<()> { - reth_tracing::init_test_tracing(); - - let chain_spec = localdev_with_denylisted_addresses(vec![WALLET_FIRST_ADDRESS]); - let addresses_denylist_config = denylist_config_enabled(Vec::new())?; - - let err = sign_and_submit_value_tx( - chain_spec, - addresses_denylist_config, - Address::random(), - U256::from(1_000_000), - ) - .await - .expect_err("Expected pool to reject tx from denylisted address"); - - let pool_err = err.downcast_ref::().expect("Expected PoolError"); - let invalid = match &pool_err.kind { - PoolErrorKind::InvalidTransaction(e) => e, - other => panic!("Expected InvalidTransaction (denylist), got: {:?}", other), - }; - let arc_err = invalid - .downcast_other_ref::() - .expect("Expected ArcTransactionValidatorError"); - assert!( - matches!( - arc_err, - ArcTransactionValidatorError::DenylistedAddressError(_) - ), - "Expected DenylistedAddressError, got: {:?}", - arc_err - ); - - Ok(()) -} - -/// Test #29: Excluded address can send value transfer and EIP-7708 log is emitted. -/// -/// When denylist is enabled but the sender is in the exclusion list, -/// the transfer proceeds and the standard EIP-7708 Transfer log is emitted. -#[tokio::test] -async fn test_denylist_exclusion_allows_transfer_with_log() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x000000000000000000000000000000000000CAFE"); - let value = U256::from(1_000_000); - - // The sender (WALLET_FIRST_ADDRESS) is denylisted but also in the exclusion list - let chain_spec = localdev_with_denylisted_addresses(vec![WALLET_FIRST_ADDRESS]); - let addresses_denylist_config = - denylist_config_enabled(vec![WALLET_FIRST_ADDRESS]).expect("denylist config"); - - ArcTestBuilder::new() - .with_setup( - ArcSetup::new() - .with_chain_spec(chain_spec) - .with_addresses_denylist_config(addresses_denylist_config), - ) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_denylist_exclusion_allows_transfer_with_log failed"); -} diff --git a/crates/execution-e2e/tests/eip7708_edge_cases.rs b/crates/execution-e2e/tests/eip7708_edge_cases.rs deleted file mode 100644 index f0d6da5b..00000000 --- a/crates/execution-e2e/tests/eip7708_edge_cases.rs +++ /dev/null @@ -1,356 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! EIP-7708 edge case e2e tests. -//! -//! Tests revert rollback, multi-log composition, inner-call semantics, -//! and unusual transfer patterns. - -mod helpers; - -use alloy_primitives::{address, U256}; -use arc_execution_e2e::{ - actions::{ - AssertTransferEvent, AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, - SendTransaction, StoreDeployedAddress, TxStatus, - }, - ArcSetup, ArcTestBuilder, -}; -use helpers::{ - constants::{NATIVE_COIN_AUTHORITY_ADDRESS, SYSTEM_ADDRESS, WALLET_FIRST_ADDRESS}, - contracts::right_pad_address, -}; -use rstest::rstest; - -/// Test #48: Send value to a reverting contract — tx reverts, no EIP-7708 log. -/// -/// When the entire CALL frame reverts, the EIP-7708 log is rolled back. -/// Deploys an actual reverting contract rather than using an existing address. -#[tokio::test] -async fn test_reverted_call_no_log() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::reverting_contract_deploy_code(); - let value = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - // Send value to the reverting contract - .with_action( - SendTransaction::new("revert_call") - .with_to_named("deploy_address") - .with_value(value) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("revert_call").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("revert_call").expect_no_logs()) - .with_action(AssertTxTrace::new("revert_call")) - .run() - .await - .expect("test_reverted_call_no_log failed"); -} - -/// Tests #49/#50: Inner CALL reverts but outer succeeds — outer log emitted, inner log rolled back. -/// -/// Deploys a reverting contract and an outer contract that forwards value to it. -/// The outer contract accepts value (emitting sender→outer log), then makes an -/// inner CALL with value to the reverting contract. The inner frame reverts, so -/// the inner value transfer log (outer→reverting) is rolled back. Only the -/// outer log remains. Parameterized over transfer amount to verify consistency. -#[rstest] -#[case::standard_value(U256::from(1_000_000))] -#[case::smaller_value(U256::from(500_000))] -#[tokio::test] -async fn test_inner_call_reverts_outer_succeeds(#[case] value: U256) { - reth_tracing::init_test_tracing(); - - let reverting_deploy = helpers::contracts::reverting_contract_deploy_code(); - let outer_deploy = helpers::contracts::call_target_with_value_contract_deploy_code(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Deploy the reverting contract (inner target) - .with_action( - SendTransaction::new("deploy_reverting") - .with_create() - .with_data(reverting_deploy) - .with_value(U256::ZERO) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy_reverting").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy_reverting")) - // Deploy the outer contract (calls target from calldata with value) - .with_action( - SendTransaction::new("deploy_outer") - .with_create() - .with_data(outer_deploy) - .with_value(U256::ZERO) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy_outer").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy_outer")) - // Call the outer contract with value, passing the reverting contract's address - // as calldata. Uses stored address instead of nonce-derived guess. - .with_action( - SendTransaction::new("call") - .with_to_named("deploy_outer_address") - .with_value(value) - .with_data_fn(|env| { - let addr = env.get_address("deploy_reverting_address").ok_or_else(|| { - eyre::eyre!("Named address 'deploy_reverting_address' not found") - })?; - Ok(right_pad_address(*addr)) - }) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("call").expect(TxStatus::Success)) - // Outer frame succeeds → sender→outer_contract log preserved. - // Inner call reverts → inner log rolled back. - .with_action(AssertTxLogs::new("call").expect_log_count(1)) - .with_action(AssertTransferEvent::new( - "call", - 0, - WALLET_FIRST_ADDRESS, - AssertTransferEvent::named("deploy_outer_address"), - value, - )) - .with_action(AssertTxTrace::new("call")) - .run() - .await - .expect("test_inner_call_reverts_outer_succeeds failed"); -} - -/// Test #51: Multiple sequential value transfers in separate blocks. -/// -/// Each block contains a value transfer, verifying logs are emitted consistently -/// across blocks and don't leak between transactions. -#[tokio::test] -async fn test_sequential_blocks_each_emit_log() { - reth_tracing::init_test_tracing(); - - let recipient_1 = address!("0x000000000000000000000000000000000000AAA1"); - let recipient_2 = address!("0x000000000000000000000000000000000000AAA2"); - let value_1 = U256::from(100_000); - let value_2 = U256::from(200_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Block 1 - .with_action( - SendTransaction::new("tx1") - .with_to(recipient_1) - .with_value(value_1), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("tx1").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("tx1") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient_1, value_1), - ) - // Block 2 - .with_action( - SendTransaction::new("tx2") - .with_to(recipient_2) - .with_value(value_2), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("tx2").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("tx2") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient_2, value_2), - ) - .with_action(AssertTxTrace::new("tx1")) - .with_action(AssertTxTrace::new("tx2")) - .run() - .await - .expect("test_sequential_blocks_each_emit_log failed"); -} - -/// Test #52: Contract calls NativeCoinAuthority precompile with value. -/// -/// Deploys a contract that forwards a CALL with value to the NativeCoinAuthority -/// precompile address. The precompile will revert (unauthorized caller), but -/// the outer frame succeeds. The outer value transfer log (sender→contract) -/// is preserved; the inner log (contract→precompile) is rolled back because -/// the precompile rejects the call. -#[tokio::test] -async fn test_contract_calls_precompile_with_value() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::call_target_with_value_contract_deploy_code(); - let value = U256::from(500_000); - - // Encode NativeCoinAuthority address as calldata target. - // The bytecode reads target via CALLDATALOAD(0) + SHR(96), extracting - // the top 20 bytes. So address must be right-padded (address at left). - let calldata = helpers::contracts::right_pad_address(NATIVE_COIN_AUTHORITY_ADDRESS); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - // Call the contract with value + precompile target in calldata - .with_action( - SendTransaction::new("call") - .with_to_named("deploy_address") - .with_value(value) - .with_data(calldata) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("call").expect(TxStatus::Success)) - // Outer transfer succeeds (sender→contract), inner reverts (contract→precompile) - // Only the outer log with exact from/to/value - .with_action(AssertTxLogs::new("call").expect_log_count(1)) - .with_action(AssertTransferEvent::new( - "call", - 0, - WALLET_FIRST_ADDRESS, - AssertTransferEvent::named("deploy_address"), - value, - )) - .with_action(AssertTxTrace::new("call")) - .run() - .await - .expect("test_contract_calls_precompile_with_value failed"); -} - -/// Test #53: Value transfer after producing multiple empty blocks. -/// -/// Verifies that EIP-7708 log emission works correctly even when -/// there are empty blocks between genesis and the transfer. -#[tokio::test] -async fn test_log_after_empty_blocks() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x0000000000000000000000000000000000CC0001"); - let value = U256::from(500_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Produce several empty blocks first - .with_action(ProduceBlocks::new(5)) - // Now send a value transfer - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_log_after_empty_blocks failed"); -} - -/// Test: Transfer to a contract that exists but has no code (EOA-like). -#[tokio::test] -async fn test_transfer_to_codeless_address() { - reth_tracing::init_test_tracing(); - - let target = address!("0x000000000000000000000000000000000000DEAD"); - let value = U256::from(1_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(target) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, target, value), - ) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_transfer_to_codeless_address failed"); -} - -/// Test: Value transfer and zero-value transfer in same block. -/// -/// Only the value transfer should emit a log; the zero-value transfer should not. -#[tokio::test] -async fn test_mixed_value_and_zero_value_in_block() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x000000000000000000000000000000000000F00D"); - let value = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("with_value") - .with_to(recipient) - .with_value(value), - ) - .with_action( - SendTransaction::new("zero_value") - .with_to(recipient) - .with_value(U256::ZERO), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("with_value").expect(TxStatus::Success)) - .with_action(AssertTxIncluded::new("zero_value").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("with_value") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .with_action(AssertTxLogs::new("zero_value").expect_no_logs()) - .run() - .await - .expect("test_mixed_value_and_zero_value_in_block failed"); -} diff --git a/crates/execution-e2e/tests/eip7708_hardfork_transition.rs b/crates/execution-e2e/tests/eip7708_hardfork_transition.rs deleted file mode 100644 index 2fc4fc2c..00000000 --- a/crates/execution-e2e/tests/eip7708_hardfork_transition.rs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! EIP-7708 hardfork transition e2e tests. -//! -//! Verifies that EIP-7708 Transfer logs activate correctly at the Zero5 boundary -//! and that pre-Zero5 blocks emit NativeCoinTransferred logs from -//! NATIVE_COIN_AUTHORITY_ADDRESS instead. - -mod helpers; - -use alloy_primitives::{address, U256}; -use arc_execution_config::hardforks::ArcHardfork; -use arc_execution_e2e::{ - actions::{ - AssertBlockNumber, AssertHardfork, AssertTxIncluded, AssertTxLogs, AssertTxTrace, - ProduceBlocks, SendTransaction, TxStatus, - }, - chainspec::localdev_with_hardforks, - ArcSetup, ArcTestBuilder, -}; -use helpers::constants::{NATIVE_COIN_AUTHORITY_ADDRESS, SYSTEM_ADDRESS, WALLET_FIRST_ADDRESS}; -use reth_chainspec::ForkCondition; - -/// Test #20: Pre-Zero5 value transfer emits NativeCoinTransferred from NativeCoinAuthority. -/// -/// Verifies exact event format: topic[0] = NativeCoinTransferred signature, -/// topic[1] = from, topic[2] = to, data = amount. -#[tokio::test] -async fn test_pre_zero5_emits_native_coin_transferred() { - reth_tracing::init_test_tracing(); - - let chain_spec = localdev_with_hardforks(&[ - (ArcHardfork::Zero3, ForkCondition::Block(0)), - (ArcHardfork::Zero4, ForkCondition::Block(0)), - (ArcHardfork::Zero5, ForkCondition::Block(100)), // far in the future - (ArcHardfork::Zero6, ForkCondition::Block(100)), - ]); - - let recipient = address!("0x000000000000000000000000000000000000bEEF"); - let value = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new().with_chain_spec(chain_spec)) - .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero5)) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_emitter_at(0, NATIVE_COIN_AUTHORITY_ADDRESS) - // Verify exact NativeCoinTransferred event topics and data - .expect_native_coin_transferred_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_pre_zero5_emits_native_coin_transferred failed"); -} - -/// Test #21: Zero5 activation boundary — tx before activation uses old log format, -/// tx after activation uses EIP-7708 format. -/// -/// Pre-Zero5 tx emits NativeCoinTransferred with exact topics/data; -/// post-Zero5 tx emits ERC-20 Transfer with exact topics/data. -#[tokio::test] -async fn test_zero5_activation_boundary() { - reth_tracing::init_test_tracing(); - - // Zero5 activates at block 3 - let chain_spec = localdev_with_hardforks(&[ - (ArcHardfork::Zero3, ForkCondition::Block(0)), - (ArcHardfork::Zero4, ForkCondition::Block(0)), - (ArcHardfork::Zero5, ForkCondition::Block(3)), - (ArcHardfork::Zero6, ForkCondition::Block(100)), - ]); - - let recipient = address!("0x000000000000000000000000000000000000bEEF"); - let value = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new().with_chain_spec(chain_spec)) - .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero5)) - // Send tx before Zero5 (block 1) - .with_action( - SendTransaction::new("pre_zero5") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertBlockNumber::new(1)) - .with_action(AssertTxIncluded::new("pre_zero5").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("pre_zero5") - .expect_log_count(1) - .expect_emitter_at(0, NATIVE_COIN_AUTHORITY_ADDRESS) - // Exact pre-Zero5 event format - .expect_native_coin_transferred_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - // Produce blocks 2-3 to reach Zero5 activation - .with_action(ProduceBlocks::new(2)) - .with_action(AssertBlockNumber::new(3)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) - // Send tx after Zero5 (block 4) - .with_action( - SendTransaction::new("post_zero5") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("post_zero5").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("post_zero5") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .with_action(AssertTxTrace::new("pre_zero5")) - .with_action(AssertTxTrace::new("post_zero5")) - .run() - .await - .expect("test_zero5_activation_boundary failed"); -} - -/// Test #22: Post-Zero5 value transfer emits Transfer from SYSTEM_ADDRESS. -#[tokio::test] -async fn test_post_zero5_emits_eip7708_transfer() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x000000000000000000000000000000000000bEEF"); - let value = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_post_zero5_emits_eip7708_transfer failed"); -} - -/// Test #23: Verify Zero5 hardfork is active at genesis on default localdev. -#[tokio::test] -async fn test_zero5_active_at_genesis() { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) - .run() - .await - .expect("test_zero5_active_at_genesis failed"); -} diff --git a/crates/execution-e2e/tests/eip7708_log_format.rs b/crates/execution-e2e/tests/eip7708_log_format.rs deleted file mode 100644 index 5bc08f55..00000000 --- a/crates/execution-e2e/tests/eip7708_log_format.rs +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! EIP-7708 log format compliance e2e tests. -//! -//! Verifies the byte-level ERC-20 Transfer log format: emitter address, -//! topic[0] (event signature), topic[1] (from), topic[2] (to), data (value). - -mod helpers; - -use alloy_primitives::{address, Bytes, B256, U256}; -use arc_execution_e2e::{ - actions::{ - AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, SendTransaction, TxStatus, - }, - ArcSetup, ArcTestBuilder, -}; -use helpers::constants::{SYSTEM_ADDRESS, TRANSFER_EVENT_SIGNATURE, WALLET_FIRST_ADDRESS}; - -/// Test #37: topic[0] matches ERC-20 Transfer(address,address,uint256) signature. -#[tokio::test] -async fn test_transfer_log_topic0_matches_erc20_signature() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x0000000000000000000000000000000000001111"); - let value = U256::from(1_000_000); - - // Build expected topics and data - let expected_topics = vec![ - TRANSFER_EVENT_SIGNATURE, - B256::left_padding_from(WALLET_FIRST_ADDRESS.as_slice()), - B256::left_padding_from(recipient.as_slice()), - ]; - let expected_data = Bytes::from(value.to_be_bytes::<32>().to_vec()); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_log_at(0, SYSTEM_ADDRESS, expected_topics, expected_data), - ) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_transfer_log_topic0_matches_erc20_signature failed"); -} - -/// Test #38: topic[1] encodes sender address as left-padded bytes32. -#[tokio::test] -async fn test_transfer_log_topic1_encodes_sender() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x0000000000000000000000000000000000002222"); - let value = U256::from(42); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .run() - .await - .expect("test_transfer_log_topic1_encodes_sender failed"); -} - -/// Test #39: topic[2] encodes recipient address as left-padded bytes32. -#[tokio::test] -async fn test_transfer_log_topic2_encodes_recipient() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x0000000000000000000000000000000000003333"); - let value = U256::from(999); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .run() - .await - .expect("test_transfer_log_topic2_encodes_recipient failed"); -} - -/// Test #40: data encodes value as big-endian uint256. -#[tokio::test] -async fn test_transfer_log_data_encodes_value() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x0000000000000000000000000000000000004444"); - // Use a distinctive value to verify encoding - let value = U256::from(0xDEADBEEFu64); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .run() - .await - .expect("test_transfer_log_data_encodes_value failed"); -} - -/// Test #41: emitter address is SYSTEM_ADDRESS, not the sender or NativeCoinAuthority. -#[tokio::test] -async fn test_transfer_log_emitter_is_system_address() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x0000000000000000000000000000000000005555"); - let value = U256::from(1); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS), - ) - .run() - .await - .expect("test_transfer_log_emitter_is_system_address failed"); -} diff --git a/crates/execution-e2e/tests/eip7708_native_transfer.rs b/crates/execution-e2e/tests/eip7708_native_transfer.rs deleted file mode 100644 index 57ddd17d..00000000 --- a/crates/execution-e2e/tests/eip7708_native_transfer.rs +++ /dev/null @@ -1,855 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! EIP-7708 native transfer e2e tests. -//! -//! Tests that native value transfers emit ERC-20 Transfer logs from SYSTEM_ADDRESS -//! under the Zero5 hardfork via CALL to EOA, contract, and precompile recipients, -//! as well as CREATE, SELFDESTRUCT, and nested value transfer scenarios. - -mod helpers; - -use alloy_primitives::{address, U256}; -use arc_execution_e2e::{ - actions::{ - AssertBalance, AssertLastOpcodeGasCost, AssertNamedBalance, AssertTransferEvent, - AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, SendTransaction, - StoreDeployedAddress, TxStatus, - }, - ArcSetup, ArcTestBuilder, -}; -use helpers::{ - constants::{SYSTEM_ADDRESS, WALLET_FIRST_ADDRESS}, - contracts::right_pad_address, -}; - -// ===== CALL to EOA (#1-3) ===== - -/// Test #1: EOA sends nonzero USDC to another EOA — emits 1 EIP-7708 Transfer log. -#[tokio::test] -async fn test_call_eoa_with_value_emits_eip7708_log() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x000000000000000000000000000000000000bEEF"); - let value = U256::from(1_000_000); // 1 USDC (6 decimals) - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action( - AssertTxIncluded::new("transfer") - .expect(TxStatus::Success) - .expect_gas_used(21_000), - ) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_call_eoa_with_value_emits_eip7708_log failed"); -} - -/// Test #2: EOA sends 0 value — no EIP-7708 log. -#[tokio::test] -async fn test_call_eoa_zero_value_no_log() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x000000000000000000000000000000000000bEEF"); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(U256::ZERO), - ) - .with_action(ProduceBlocks::new(1)) - .with_action( - AssertTxIncluded::new("transfer") - .expect(TxStatus::Success) - .expect_gas_used(21_000), - ) - .with_action(AssertTxLogs::new("transfer").expect_no_logs()) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_call_eoa_zero_value_no_log failed"); -} - -/// Test #3: EOA sends value to self — no EIP-7708 log (self-transfer is suppressed). -#[tokio::test] -async fn test_call_eoa_self_transfer_no_log() { - reth_tracing::init_test_tracing(); - - let value = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(WALLET_FIRST_ADDRESS) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action( - AssertTxIncluded::new("transfer") - .expect(TxStatus::Success) - .expect_gas_used(21_000), - ) - .with_action(AssertTxLogs::new("transfer").expect_no_logs()) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_call_eoa_self_transfer_no_log failed"); -} - -// ===== CALL to Contract (#4-6) ===== - -/// Test #4: EOA sends value to a payable contract — emits exact EIP-7708 Transfer log. -/// -/// Deploys a payable contract via CREATE, then sends value to it. -/// Asserts exact from (sender), to (deployed contract), and value using stored addresses. -#[tokio::test] -async fn test_call_contract_with_value_emits_eip7708_log() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::payable_contract_deploy_code(); - let transfer_value = U256::from(500_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Deploy payable contract - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - // Send value to the deployed contract - .with_action( - SendTransaction::new("value_call") - .with_to_named("deploy_address") - .with_value(transfer_value) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("value_call").expect(TxStatus::Success)) - .with_action(AssertTxLogs::new("value_call").expect_log_count(1)) - .with_action(AssertTransferEvent::new( - "value_call", - 0, - WALLET_FIRST_ADDRESS, - AssertTransferEvent::named("deploy_address"), - transfer_value, - )) - .with_action(AssertNamedBalance::of("deploy_address").equals(transfer_value)) - .with_action(AssertTxTrace::new("value_call")) - .run() - .await - .expect("test_call_contract_with_value_emits_eip7708_log failed"); -} - -/// Test #5: EOA sends 0 value to a contract — no EIP-7708 log. -#[tokio::test] -async fn test_call_contract_zero_value_no_log() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::payable_contract_deploy_code(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - .with_action( - SendTransaction::new("zero_call") - .with_to_named("deploy_address") - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("zero_call").expect(TxStatus::Success)) - .with_action(AssertTxLogs::new("zero_call").expect_no_logs()) - .with_action(AssertTxTrace::new("zero_call")) - .run() - .await - .expect("test_call_contract_zero_value_no_log failed"); -} - -/// Test #6: EOA sends value to a reverting contract — tx reverts, no EIP-7708 log. -#[tokio::test] -async fn test_call_reverting_contract_with_value_no_log() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::reverting_contract_deploy_code(); - let value = U256::from(500_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - .with_action( - SendTransaction::new("revert_call") - .with_to_named("deploy_address") - .with_value(value) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("revert_call").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("revert_call").expect_no_logs()) - .with_action(AssertTxTrace::new("revert_call")) - .run() - .await - .expect("test_call_reverting_contract_with_value_no_log failed"); -} - -// ===== CALL to Precompile (#7-8) ===== - -/// Test #7: CALL to precompile with value — reverts (unauthorized), logs rolled back. -#[tokio::test] -async fn test_call_precompile_with_value() { - reth_tracing::init_test_tracing(); - - let precompile = address!("0x1800000000000000000000000000000000000000"); - let value = U256::from(1_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("call_precompile") - .with_to(precompile) - .with_value(value) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("call_precompile").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("call_precompile").expect_no_logs()) - .with_action(AssertTxTrace::new("call_precompile")) - .run() - .await - .expect("test_call_precompile_with_value failed"); -} - -/// Test #8: CALL to precompile with 0 value — no EIP-7708 log. -#[tokio::test] -async fn test_call_precompile_zero_value_no_log() { - reth_tracing::init_test_tracing(); - - let precompile = address!("0x1800000000000000000000000000000000000000"); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("call_precompile") - .with_to(precompile) - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("call_precompile").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("call_precompile").expect_no_logs()) - .with_action(AssertTxTrace::new("call_precompile")) - .run() - .await - .expect("test_call_precompile_zero_value_no_log failed"); -} - -// ===== CREATE (#9-10) ===== - -/// Test #9: CREATE with nonzero value — emits exact EIP-7708 Transfer log. -/// -/// When deploying a contract with value (endowment), the value transfer from -/// the deployer to the new contract address emits an EIP-7708 Transfer log. -/// Asserts exact from (sender), to (deployed address from receipt), and value. -#[tokio::test] -async fn test_create_with_value_emits_eip7708_log() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::payable_contract_deploy_code(); - let endowment = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("create") - .with_create() - .with_data(deploy_code) - .with_value(endowment) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("create").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("create")) - .with_action(AssertTxLogs::new("create").expect_log_count(1)) - .with_action(AssertTransferEvent::new( - "create", - 0, - WALLET_FIRST_ADDRESS, - AssertTransferEvent::named("create_address"), - endowment, - )) - .with_action(AssertNamedBalance::of("create_address").equals(endowment)) - .with_action(AssertTxTrace::new("create")) - .run() - .await - .expect("test_create_with_value_emits_eip7708_log failed"); -} - -/// Test #10: CREATE with zero value — no EIP-7708 Transfer log. -#[tokio::test] -async fn test_create_zero_value_no_log() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::payable_contract_deploy_code(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("create") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("create").expect(TxStatus::Success)) - .with_action(AssertTxLogs::new("create").expect_no_logs()) - .with_action(AssertTxTrace::new("create")) - .run() - .await - .expect("test_create_zero_value_no_log failed"); -} - -/// Test: CREATE with nonzero value where constructor reverts — tx reverts, no log, no balance leak. -/// -/// Sends a CREATE tx with endowment but the constructor reverts. -/// The EIP-7708 Transfer log is rolled back with the frame. -/// The would-be contract address must not have any balance. -#[tokio::test] -async fn test_create_revert_with_endowment_no_log() { - reth_tracing::init_test_tracing(); - - let initcode = helpers::contracts::reverting_constructor_code(); - let endowment = U256::from(1_000_000); - - // Nonce-derived address is necessary here because the CREATE reverts — - // StoreDeployedAddress cannot recover the address from a failed CREATE. - // We compute it to verify no balance leaked to the would-be address. - let would_be_addr = WALLET_FIRST_ADDRESS.create(0); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("create") - .with_create() - .with_data(initcode) - .with_value(endowment) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - // Constructor reverts → tx reverts - .with_action(AssertTxIncluded::new("create").expect(TxStatus::Reverted)) - // Transfer log rolled back - .with_action(AssertTxLogs::new("create").expect_no_logs()) - // No balance leakage to the would-be contract address - .with_action(AssertBalance::new(would_be_addr, U256::ZERO)) - .with_action(AssertTxTrace::new("create")) - .run() - .await - .expect("test_create_revert_with_endowment_no_log failed"); -} - -/// Test: successful CREATE2 with value leaves the created address warm. -/// -/// The probe contract funds a CREATE2 child with 1 wei and immediately executes -/// BALANCE on the precomputed created address passed in calldata. A successful -/// CREATE2 warms the child through revm's create handler, so that BALANCE costs -/// 100 gas. -#[tokio::test] -async fn test_create2_with_value_balance_probe_is_warm_after_successful_create() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::create2_with_balance_probe(); - let endowment = U256::from(1); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("deploy_probe") - .with_create() - .with_data(deploy_code) - .with_value(endowment) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy_probe").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy_probe")) - .with_action( - SendTransaction::new("run_probe") - .with_to_named("deploy_probe_address") - .with_value(U256::ZERO) - .with_data_fn(|env| { - let probe = *env.get_address("deploy_probe_address").ok_or_else(|| { - eyre::eyre!("Named address 'deploy_probe_address' not found") - })?; - Ok(helpers::contracts::create2_balance_probe_calldata(probe)) - }) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("run_probe").expect(TxStatus::Success)) - .with_action(AssertLastOpcodeGasCost::new("run_probe", "BALANCE", 100)) - .run() - .await - .expect("test_create2_with_value_balance_probe_is_warm_after_successful_create failed"); -} - -/// Test: out-of-funds CREATE2 with value does not warm the would-be created -/// address via Arc's selfdestruct-target probe. -/// -/// The probe has zero balance, so its CREATE2(value=1) fails before revm's -/// create handler warms the derived child address. The probe then checks BALANCE -/// for that precomputed address in the same frame. Under Zero7, Arc's -/// selfdestruct-target check is checkpointed, so BALANCE remains cold and costs -/// 2600 gas. -#[tokio::test] -async fn test_create2_out_of_funds_keeps_balance_probe_cold() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::create2_with_balance_probe(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("deploy_probe") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy_probe").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy_probe")) - .with_action( - SendTransaction::new("run_probe") - .with_to_named("deploy_probe_address") - .with_value(U256::ZERO) - .with_data_fn(|env| { - let probe = *env.get_address("deploy_probe_address").ok_or_else(|| { - eyre::eyre!("Named address 'deploy_probe_address' not found") - })?; - Ok(helpers::contracts::create2_balance_probe_calldata(probe)) - }) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("run_probe").expect(TxStatus::Success)) - .with_action(AssertLastOpcodeGasCost::new("run_probe", "BALANCE", 2600)) - .run() - .await - .expect("test_create2_out_of_funds_keeps_balance_probe_cold failed"); -} - -// ===== SELFDESTRUCT (#11-18) ===== - -/// Test #11: SELFDESTRUCT sends balance to beneficiary — emits exact EIP-7708 Transfer log. -/// -/// Asserts: from = contract address (stored), to = beneficiary, value = endowment. -#[tokio::test] -async fn test_selfdestruct_with_balance_emits_log() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::selfdestruct_contract_deploy_code(); - let endowment = U256::from(1_000_000); - let beneficiary = address!("0x000000000000000000000000000000000000BEEF"); - - let calldata = helpers::contracts::right_pad_address(beneficiary); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Deploy selfdestruct contract with endowment - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(endowment) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - // Trigger selfdestruct — sends balance to beneficiary - .with_action( - SendTransaction::new("selfdestruct") - .with_to_named("deploy_address") - .with_value(U256::ZERO) - .with_data(calldata) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("selfdestruct").expect(TxStatus::Success)) - // Exact Transfer: from=stored contract addr, to=beneficiary, value=endowment - .with_action(AssertTxLogs::new("selfdestruct").expect_log_count(1)) - .with_action(AssertTransferEvent::new( - "selfdestruct", - 0, - AssertTransferEvent::named("deploy_address"), - beneficiary, - endowment, - )) - .with_action(AssertNamedBalance::of("deploy_address").equals(U256::ZERO)) - .with_action(AssertTxTrace::new("selfdestruct")) - .run() - .await - .expect("test_selfdestruct_with_balance_emits_log failed"); -} - -/// Test #12: SELFDESTRUCT with zero balance — no EIP-7708 Transfer log. -#[tokio::test] -async fn test_selfdestruct_zero_balance_no_log() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::selfdestruct_contract_deploy_code(); - let beneficiary = address!("0x000000000000000000000000000000000000BEEF"); - - let calldata = helpers::contracts::right_pad_address(beneficiary); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Deploy with zero balance - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - // Trigger selfdestruct — zero balance to transfer - .with_action( - SendTransaction::new("selfdestruct") - .with_to_named("deploy_address") - .with_value(U256::ZERO) - .with_data(calldata) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("selfdestruct").expect(TxStatus::Success)) - .with_action(AssertTxLogs::new("selfdestruct").expect_no_logs()) - .with_action(AssertTxTrace::new("selfdestruct")) - .run() - .await - .expect("test_selfdestruct_zero_balance_no_log failed"); -} - -/// Test #13: SELFDESTRUCT to self — beneficiary == contract address. -/// -/// The implementation explicitly rejects SELFDESTRUCT where source == target -/// with nonzero balance under Zero5 (see `check_selfdestruct_accounts` in opcode.rs). -/// The SELFDESTRUCT opcode halts with Revert, causing the tx to revert. -/// No log is emitted and the contract retains its balance. -#[tokio::test] -async fn test_selfdestruct_to_self_reverts() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::selfdestruct_contract_deploy_code(); - let endowment = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(endowment) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - // Trigger selfdestruct to self — implementation rejects this - .with_action( - SendTransaction::new("selfdestruct") - .with_to_named("deploy_address") - .with_value(U256::ZERO) - .with_data_fn(|env| { - let addr = env - .get_address("deploy_address") - .ok_or_else(|| eyre::eyre!("Named address 'deploy_address' not found"))?; - Ok(right_pad_address(*addr)) - }) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - // SELFDESTRUCT to self reverts the tx under Zero5 - .with_action(AssertTxIncluded::new("selfdestruct").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("selfdestruct").expect_no_logs()) - // Contract retains its balance — no state change - .with_action(AssertNamedBalance::of("deploy_address").equals(endowment)) - .with_action(AssertTxTrace::new("selfdestruct")) - .run() - .await - .expect("test_selfdestruct_to_self_reverts failed"); -} - -// ===== Nested/Forwarded Transfer (#19) ===== - -/// Test #19: Contract forwards received value to another address — both transfers emit exact logs. -/// -/// Deploys a forwarder contract, then sends value to it with a target address. -/// The forwarder CALLs the target with the received value (CALLVALUE). -/// Expected: 2 EIP-7708 Transfer logs in order: -/// log[0]: Transfer(sender, forwarder, value) -/// log[1]: Transfer(forwarder, final_recipient, value) -#[tokio::test] -async fn test_nested_value_transfer_emits_multiple_logs() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::forwarder_contract_deploy_code(); - let final_recipient = address!("0x000000000000000000000000000000000000CAFE"); - let value = U256::from(500_000); - - let calldata = helpers::contracts::right_pad_address(final_recipient); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Deploy forwarder - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - // Send value to forwarder with target in calldata - .with_action( - SendTransaction::new("forward") - .with_to_named("deploy_address") - .with_value(value) - .with_data(calldata) - .with_gas_limit(200_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("forward").expect(TxStatus::Success)) - // Exact 2 logs: sender→forwarder, forwarder→final_recipient - .with_action(AssertTxLogs::new("forward").expect_log_count(2)) - .with_action(AssertTransferEvent::new( - "forward", - 0, - WALLET_FIRST_ADDRESS, - AssertTransferEvent::named("deploy_address"), - value, - )) - .with_action(AssertTransferEvent::new( - "forward", - 1, - AssertTransferEvent::named("deploy_address"), - final_recipient, - value, - )) - .with_action(AssertNamedBalance::of("deploy_address").equals(U256::ZERO)) - .with_action(AssertTxTrace::new("forward")) - .run() - .await - .expect("test_nested_value_transfer_emits_multiple_logs failed"); -} - -// ===== Additional coverage ===== - -/// Test: large value transfer emits correct log. -#[tokio::test] -async fn test_large_value_transfer() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x0000000000000000000000000000000000001234"); - let value = U256::from(10_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_large_value_transfer failed"); -} - -/// Test: minimum value (1 wei) transfer emits correct log. -#[tokio::test] -async fn test_min_value_transfer() { - reth_tracing::init_test_tracing(); - - let recipient = address!("0x0000000000000000000000000000000000005678"); - let value = U256::from(1); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(recipient) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertTxLogs::new("transfer") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient, value), - ) - .with_action(AssertTxTrace::new("transfer")) - .run() - .await - .expect("test_min_value_transfer failed"); -} - -/// Test: multiple value transfers in one block each emit their own log. -#[tokio::test] -async fn test_multiple_transfers_in_block() { - reth_tracing::init_test_tracing(); - - let recipient_a = address!("0x000000000000000000000000000000000000aaaa"); - let recipient_b = address!("0x000000000000000000000000000000000000bbbb"); - let value_a = U256::from(100_000); - let value_b = U256::from(200_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("tx_a") - .with_to(recipient_a) - .with_value(value_a), - ) - .with_action( - SendTransaction::new("tx_b") - .with_to(recipient_b) - .with_value(value_b), - ) - .with_action(ProduceBlocks::new(1)) - .with_action( - AssertTxIncluded::new("tx_a") - .expect(TxStatus::Success) - .expect_gas_used(21_000), - ) - .with_action( - AssertTxIncluded::new("tx_b") - .expect(TxStatus::Success) - .expect_gas_used(21_000), - ) - .with_action( - AssertTxLogs::new("tx_a") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient_a, value_a), - ) - .with_action( - AssertTxLogs::new("tx_b") - .expect_log_count(1) - .expect_emitter_at(0, SYSTEM_ADDRESS) - .expect_transfer_event(0, WALLET_FIRST_ADDRESS, recipient_b, value_b), - ) - .with_action(AssertTxTrace::new("tx_a")) - .with_action(AssertTxTrace::new("tx_b")) - .run() - .await - .expect("test_multiple_transfers_in_block failed"); -} - -/// Test: reverted value transfer to reverting contract does not leak balance. -/// -/// Sends value to a reverting contract. The tx reverts, so no value is transferred. -/// Asserts the target contract's balance remains zero after the revert. -#[tokio::test] -async fn test_reverted_value_transfer_balance_unchanged() { - reth_tracing::init_test_tracing(); - - let deploy_code = helpers::contracts::reverting_contract_deploy_code(); - let value = U256::from(500_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("deploy") - .with_create() - .with_data(deploy_code) - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("deploy").expect(TxStatus::Success)) - .with_action(StoreDeployedAddress::new("deploy")) - // Confirm contract starts with zero balance - .with_action(AssertNamedBalance::of("deploy_address").equals(U256::ZERO)) - // Attempt value transfer — will revert - .with_action( - SendTransaction::new("revert_call") - .with_to_named("deploy_address") - .with_value(value) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("revert_call").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("revert_call").expect_no_logs()) - // Contract balance still zero — no value leaked through revert - .with_action(AssertNamedBalance::of("deploy_address").equals(U256::ZERO)) - .run() - .await - .expect("test_reverted_value_transfer_balance_unchanged failed"); -} diff --git a/crates/execution-e2e/tests/eip7708_payload_validation.rs b/crates/execution-e2e/tests/eip7708_payload_validation.rs deleted file mode 100644 index 3e7014a8..00000000 --- a/crates/execution-e2e/tests/eip7708_payload_validation.rs +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! EIP-7708 payload validation e2e tests. -//! -//! Verifies that Engine API accepts payloads containing EIP-7708 Transfer logs -//! and rejects payloads with corrupted state roots. - -use alloy_primitives::U256; -use alloy_rpc_types_engine::PayloadStatusEnum; -use arc_execution_e2e::{ - actions::{ - assert_valid_or_syncing, build_payload_for_next_block, set_payload_override_and_rehash, - submit_payload, AssertTxIncluded, ProduceBlocks, SendTransaction, TxStatus, - }, - Action, ArcEnvironment, ArcSetup, -}; -use eyre::Result; - -/// Test #42: Payload with EIP-7708 Transfer log is accepted as VALID. -#[tokio::test] -async fn test_payload_with_eip7708_log_accepted() -> Result<()> { - reth_tracing::init_test_tracing(); - - let mut env = ArcEnvironment::new(); - ArcSetup::new().apply(&mut env).await?; - - // Produce block 1 with a value transfer (triggers EIP-7708 log) - let mut send = SendTransaction::new("transfer") - .with_to(alloy_primitives::address!( - "0x000000000000000000000000000000000000bEEF" - )) - .with_value(U256::from(1_000_000)); - send.execute(&mut env).await?; - - let mut produce = ProduceBlocks::new(1); - produce.execute(&mut env).await?; - - // Verify the tx was included successfully - let mut assert_included = AssertTxIncluded::new("transfer").expect(TxStatus::Success); - assert_included.execute(&mut env).await?; - - // Now build the next payload and submit via Engine API - let (payload, execution_requests, parent_beacon_block_root) = - build_payload_for_next_block(&env).await?; - - let status = - submit_payload(&env, payload, execution_requests, parent_beacon_block_root).await?; - - assert_valid_or_syncing(&status, "EIP-7708 payload")?; - - Ok(()) -} - -/// Test #43: Payload with corrupted stateRoot after EIP-7708 tx is rejected as INVALID. -#[tokio::test] -async fn test_payload_with_corrupted_state_root_rejected() -> Result<()> { - reth_tracing::init_test_tracing(); - - let mut env = ArcEnvironment::new(); - ArcSetup::new().apply(&mut env).await?; - - // Produce block 1 with a value transfer - let mut send = SendTransaction::new("transfer") - .with_to(alloy_primitives::address!( - "0x000000000000000000000000000000000000bEEF" - )) - .with_value(U256::from(1_000_000)); - send.execute(&mut env).await?; - - let mut produce = ProduceBlocks::new(1); - produce.execute(&mut env).await?; - - // Build next payload - let (mut payload, execution_requests, parent_beacon_block_root) = - build_payload_for_next_block(&env).await?; - - // Corrupt the state root - let mut payload_override = payload.payload_inner.payload_inner.clone(); - payload_override.state_root = alloy_primitives::B256::repeat_byte(0xDE); - set_payload_override_and_rehash( - &mut payload, - &execution_requests, - parent_beacon_block_root, - payload_override, - )?; - - let status = - submit_payload(&env, payload, execution_requests, parent_beacon_block_root).await?; - - assert!( - matches!(status, PayloadStatusEnum::Invalid { .. }), - "Expected INVALID status for corrupted state root, got {status:?}" - ); - - Ok(()) -} diff --git a/crates/execution-e2e/tests/eip7708_precompile.rs b/crates/execution-e2e/tests/eip7708_precompile.rs deleted file mode 100644 index 264de985..00000000 --- a/crates/execution-e2e/tests/eip7708_precompile.rs +++ /dev/null @@ -1,471 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! EIP-7708 precompile interaction e2e tests. -//! -//! Tests cover both unauthorized (revert) and authorized (success) paths for -//! NativeCoinAuthority precompile operations (mint, burn, transfer). -//! -//! Under Zero5, the NativeCoinAuthority precompile only accepts calls from -//! `NATIVE_FIAT_TOKEN_ADDRESS` (0x3600..0000). Direct EOA calls are rejected. -//! Authorized calls go through the NativeFiatToken contract, which delegates -//! to the precompile. The operator wallet (index 7 in localdev genesis) has -//! the minter role. - -mod helpers; - -use alloy_primitives::{address, Address, Bytes, U256}; -use alloy_sol_types::{sol, SolCall}; -use arc_execution_e2e::{ - actions::{ - AssertBalance, AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, - SendTransaction, TxStatus, - }, - ArcSetup, ArcTestBuilder, -}; -use helpers::constants::NATIVE_COIN_AUTHORITY_ADDRESS; - -/// NativeFiatToken proxy contract address — the only caller authorized to invoke -/// NativeCoinAuthority under Zero5. -const NATIVE_FIAT_TOKEN_ADDRESS: Address = address!("0x3600000000000000000000000000000000000000"); - -/// NativeCoinControl precompile address. -const NATIVE_COIN_CONTROL_ADDRESS: Address = address!("0x1800000000000000000000000000000000000001"); - -/// Operator wallet index in localdev genesis (has minter role on NativeFiatToken). -const WALLET_OPERATOR_INDEX: usize = 7; - -/// NativeFiatToken uses 6 decimals; the precompile operates in 18-decimal native units. -/// NativeFiatToken converts by multiplying by 10^12 before calling the precompile. -/// So 1 USDC (1_000_000 in 6-dec) becomes 10^18 in the precompile's event and balance. -const USDC_TO_NATIVE: U256 = U256::from_limbs([1_000_000_000_000u64, 0, 0, 0]); // 10^12 - -sol! { - /// NativeFiatToken contract ABI (authorized path — operator calls these). - interface INativeFiatToken { - function mint(address to, uint256 amount) public; - function burn(uint256 amount) public; - function transfer(address to, uint256 amount) public returns (bool); - } - - /// NativeCoinAuthority precompile ABI (unauthorized path — direct calls). - interface INativeCoinAuthority { - function mint(address to, uint256 amount) external returns (bool); - function burn(address from, uint256 amount) external returns (bool); - function transfer(address from, address to, uint256 amount) external returns (bool); - function totalSupply() external view returns (uint256 supply); - } -} - -// ===== Unauthorized paths (#30-32): Direct EOA calls to precompile ===== - -/// Test #30: Direct unauthorized call to NativeCoinAuthority mint — reverts, no EIP-7708 log. -#[tokio::test] -async fn test_unauthorized_mint_call_reverts_no_log() { - reth_tracing::init_test_tracing(); - - let calldata = INativeCoinAuthority::mintCall { - to: address!("0x000000000000000000000000000000000000bEEF"), - amount: U256::from(1_000_000), - } - .abi_encode(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("mint_call") - .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) - .with_value(U256::ZERO) - .with_data(Bytes::from(calldata)) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("mint_call").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("mint_call").expect_no_logs()) - .with_action(AssertTxTrace::new("mint_call")) - .run() - .await - .expect("test_unauthorized_mint_call_reverts_no_log failed"); -} - -/// Test #31: Direct unauthorized call to NativeCoinAuthority burn — reverts, no EIP-7708 log. -#[tokio::test] -async fn test_unauthorized_burn_call_reverts_no_log() { - reth_tracing::init_test_tracing(); - - let calldata = INativeCoinAuthority::burnCall { - from: address!("0x000000000000000000000000000000000000bEEF"), - amount: U256::from(1_000), - } - .abi_encode(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("burn_call") - .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) - .with_value(U256::ZERO) - .with_data(Bytes::from(calldata)) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("burn_call").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("burn_call").expect_no_logs()) - .with_action(AssertTxTrace::new("burn_call")) - .run() - .await - .expect("test_unauthorized_burn_call_reverts_no_log failed"); -} - -/// Test #32: Direct unauthorized call to NativeCoinAuthority transfer — reverts, no EIP-7708 log. -#[tokio::test] -async fn test_unauthorized_transfer_call_reverts_no_log() { - reth_tracing::init_test_tracing(); - - let calldata = INativeCoinAuthority::transferCall { - from: address!("0x000000000000000000000000000000000000bEEF"), - to: address!("0x000000000000000000000000000000000000CAFE"), - amount: U256::from(1_000), - } - .abi_encode(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer_call") - .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) - .with_value(U256::ZERO) - .with_data(Bytes::from(calldata)) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer_call").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("transfer_call").expect_no_logs()) - .with_action(AssertTxTrace::new("transfer_call")) - .run() - .await - .expect("test_unauthorized_transfer_call_reverts_no_log failed"); -} - -// ===== Value to precompile addresses (#33-34) ===== - -/// Test #33: Value transfer to NativeCoinAuthority — reverts, no log. -#[tokio::test] -async fn test_value_to_native_coin_authority() { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("value_call") - .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) - .with_value(U256::from(1_000)) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("value_call").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("value_call").expect_no_logs()) - .with_action(AssertTxTrace::new("value_call")) - .run() - .await - .expect("test_value_to_native_coin_authority failed"); -} - -/// Test #34: Value transfer to NativeCoinControl — reverts, no log. -#[tokio::test] -async fn test_value_to_native_coin_control() { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("value_call") - .with_to(NATIVE_COIN_CONTROL_ADDRESS) - .with_value(U256::from(1_000)) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("value_call").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("value_call").expect_no_logs()) - .with_action(AssertTxTrace::new("value_call")) - .run() - .await - .expect("test_value_to_native_coin_control failed"); -} - -/// Test #35: Zero-value call to NativeFiatToken — no EIP-7708 log. -#[tokio::test] -async fn test_zero_value_call_to_native_fiat_token() { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("call") - .with_to(NATIVE_FIAT_TOKEN_ADDRESS) - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("call").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("call").expect_no_logs()) - .with_action(AssertTxTrace::new("call")) - .run() - .await - .expect("test_zero_value_call_to_native_fiat_token failed"); -} - -/// Test #36: Direct totalSupply read — succeeds without log. -#[tokio::test] -async fn test_total_supply_read_no_log() { - reth_tracing::init_test_tracing(); - - let calldata = Bytes::from(INativeCoinAuthority::totalSupplyCall {}.abi_encode()); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("total_supply") - .with_to(NATIVE_COIN_AUTHORITY_ADDRESS) - .with_value(U256::ZERO) - .with_data(calldata) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("total_supply").expect(TxStatus::Success)) - .with_action(AssertTxLogs::new("total_supply").expect_no_logs()) - .with_action(AssertTxTrace::new("total_supply")) - .run() - .await - .expect("test_total_supply_read_no_log failed"); -} - -// ===== Authorized paths: NativeFiatToken mint/burn ===== - -/// Test: Authorized mint via NativeFiatToken — emits EIP-7708 Transfer log + Mint event. -/// -/// The operator (wallet index 7) calls NativeFiatToken.mint(to, amount). -/// NativeFiatToken delegates to NativeCoinAuthority precompile. -/// Under Zero5, the precompile emits an EIP-7708 Transfer log from SYSTEM_ADDRESS -/// for the minted amount, plus the Solidity-level Mint and Transfer events from -/// the NativeFiatToken contract. -#[tokio::test] -async fn test_authorized_mint_via_native_fiat_token() { - reth_tracing::init_test_tracing(); - - let mint_recipient = address!("0x000000000000000000000000000000000000CAFE"); - // NativeFiatToken uses 6 decimals. Mint 1 USDC = 1_000_000 (6 decimals). - // The precompile converts this to 18-decimal native units internally. - let mint_amount_usdc = U256::from(1_000_000u64); - - let calldata = INativeFiatToken::mintCall { - to: mint_recipient, - amount: mint_amount_usdc, - } - .abi_encode(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("mint") - .with_to(NATIVE_FIAT_TOKEN_ADDRESS) - .with_value(U256::ZERO) - .with_data(Bytes::from(calldata)) - .with_gas_limit(500_000) - .with_wallet_index(WALLET_OPERATOR_INDEX), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("mint").expect(TxStatus::Success)) - // The precompile emits Transfer(0x0, to, amount) from SYSTEM_ADDRESS under Zero5. - // NativeFiatToken converts 6-dec USDC to 18-dec native before calling the precompile, - // so the event amount is in 18-decimal native units. - .with_action( - AssertTxLogs::new("mint").expect_transfer_event( - 0, - Address::ZERO, - mint_recipient, - mint_amount_usdc - .checked_mul(USDC_TO_NATIVE) - .expect("usdc to native overflow"), - ), - ) - .with_action(AssertTxTrace::new("mint")) - // Verify recipient balance in 18-decimal native units - .with_action(AssertBalance::new( - mint_recipient, - mint_amount_usdc - .checked_mul(USDC_TO_NATIVE) - .expect("usdc to native overflow"), - )) - .run() - .await - .expect("test_authorized_mint_via_native_fiat_token failed"); -} - -/// Test: Authorized burn via NativeFiatToken — emits EIP-7708 Transfer log + Burn event. -/// -/// Burns tokens from the operator's own balance. Requires the operator to have -/// balance, so we first mint to the operator, then burn. -#[tokio::test] -async fn test_authorized_burn_via_native_fiat_token() { - reth_tracing::init_test_tracing(); - - // Operator address (wallet index 7) - let operator = { - let wallet = reth_e2e_test_utils::wallet::Wallet::new(10).with_chain_id(1337); - wallet.wallet_gen()[WALLET_OPERATOR_INDEX].address() - }; - - let mint_amount = U256::from(2_000_000u64); // 2 USDC - let burn_amount = U256::from(1_000_000u64); // 1 USDC - - // Mint to operator first - let mint_calldata = INativeFiatToken::mintCall { - to: operator, - amount: mint_amount, - } - .abi_encode(); - - let burn_calldata = INativeFiatToken::burnCall { - amount: burn_amount, - } - .abi_encode(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Step 1: Mint to operator - .with_action( - SendTransaction::new("mint") - .with_to(NATIVE_FIAT_TOKEN_ADDRESS) - .with_value(U256::ZERO) - .with_data(Bytes::from(mint_calldata)) - .with_gas_limit(500_000) - .with_wallet_index(WALLET_OPERATOR_INDEX), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("mint").expect(TxStatus::Success)) - // Step 2: Burn from operator - .with_action( - SendTransaction::new("burn") - .with_to(NATIVE_FIAT_TOKEN_ADDRESS) - .with_value(U256::ZERO) - .with_data(Bytes::from(burn_calldata)) - .with_gas_limit(500_000) - .with_wallet_index(WALLET_OPERATOR_INDEX), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("burn").expect(TxStatus::Success)) - // Burn emits Transfer(operator, 0x0, amount) from SYSTEM_ADDRESS under Zero5. - // Amount is in 18-decimal native units (NativeFiatToken converts before calling precompile). - .with_action( - AssertTxLogs::new("burn").expect_transfer_event( - 0, - operator, - Address::ZERO, - burn_amount - .checked_mul(USDC_TO_NATIVE) - .expect("usdc to native overflow"), - ), - ) - .with_action(AssertTxTrace::new("burn")) - // Note: operator balance assertion omitted because the operator is funded in genesis - // and pays gas in USDC, making the exact post-burn balance variable. - // The Transfer log assertion above verifies the burn semantics. - .run() - .await - .expect("test_authorized_burn_via_native_fiat_token failed"); -} - -/// Test: Authorized transfer via NativeFiatToken — emits exact EIP-7708 Transfer log. -/// -/// Mints to the operator, then the operator calls NativeFiatToken.transfer(to, amount). -/// NativeFiatToken delegates to NativeCoinAuthority.transfer(from, to, amount). -/// Under Zero5, the precompile emits Transfer(from, to, amount) from SYSTEM_ADDRESS. -/// Verifies exact log fields and balance side effects. -#[tokio::test] -async fn test_authorized_transfer_via_native_fiat_token() { - reth_tracing::init_test_tracing(); - - let operator = { - let wallet = reth_e2e_test_utils::wallet::Wallet::new(10).with_chain_id(1337); - wallet.wallet_gen()[WALLET_OPERATOR_INDEX].address() - }; - - let transfer_recipient = address!("0x000000000000000000000000000000000000D00D"); - let mint_amount = U256::from(2_000_000u64); // 2 USDC - let transfer_amount = U256::from(1_000_000u64); // 1 USDC - - let mint_calldata = INativeFiatToken::mintCall { - to: operator, - amount: mint_amount, - } - .abi_encode(); - - let transfer_calldata = INativeFiatToken::transferCall { - to: transfer_recipient, - amount: transfer_amount, - } - .abi_encode(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Mint to operator first - .with_action( - SendTransaction::new("mint") - .with_to(NATIVE_FIAT_TOKEN_ADDRESS) - .with_value(U256::ZERO) - .with_data(Bytes::from(mint_calldata)) - .with_gas_limit(500_000) - .with_wallet_index(WALLET_OPERATOR_INDEX), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("mint").expect(TxStatus::Success)) - // Transfer from operator to recipient - .with_action( - SendTransaction::new("transfer") - .with_to(NATIVE_FIAT_TOKEN_ADDRESS) - .with_value(U256::ZERO) - .with_data(Bytes::from(transfer_calldata)) - .with_gas_limit(500_000) - .with_wallet_index(WALLET_OPERATOR_INDEX), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - // Transfer emits Transfer(operator, recipient, amount) from SYSTEM_ADDRESS. - // Amount is in 18-decimal native units. - .with_action( - AssertTxLogs::new("transfer").expect_transfer_event( - 0, - operator, - transfer_recipient, - transfer_amount - .checked_mul(USDC_TO_NATIVE) - .expect("usdc to native overflow"), - ), - ) - .with_action(AssertTxTrace::new("transfer")) - // Operator balance omitted (genesis-funded + gas costs make exact value variable). - // Recipient starts at zero and doesn't pay gas, so exact balance is deterministic. - .with_action(AssertBalance::new( - transfer_recipient, - transfer_amount - .checked_mul(USDC_TO_NATIVE) - .expect("usdc to native overflow"), - )) - .run() - .await - .expect("test_authorized_transfer_via_native_fiat_token failed"); -} diff --git a/crates/execution-e2e/tests/eip7708_zero_address.rs b/crates/execution-e2e/tests/eip7708_zero_address.rs deleted file mode 100644 index 54fb913e..00000000 --- a/crates/execution-e2e/tests/eip7708_zero_address.rs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! EIP-7708 zero address e2e tests. -//! -//! Arc custom behavior: value transfers to Address::ZERO are rejected under Zero5. - -mod helpers; - -use alloy_primitives::{Address, U256}; -use arc_execution_config::hardforks::ArcHardfork; -use arc_execution_e2e::{ - actions::{ - AssertTxIncluded, AssertTxLogs, AssertTxTrace, ProduceBlocks, SendTransaction, TxStatus, - }, - chainspec::localdev_with_hardforks, - ArcSetup, ArcTestBuilder, -}; -use reth_chainspec::ForkCondition; - -/// Test #24: Send value to Address::ZERO under Zero5 — tx reverts. -#[tokio::test] -async fn test_zero_address_value_transfer_reverts() { - reth_tracing::init_test_tracing(); - - let value = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("zero_addr") - .with_to(Address::ZERO) - .with_value(value) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("zero_addr").expect(TxStatus::Reverted)) - .with_action(AssertTxLogs::new("zero_addr").expect_no_logs()) - .with_action(AssertTxTrace::new("zero_addr")) - .run() - .await - .expect("test_zero_address_value_transfer_reverts failed"); -} - -/// Test #25: Send zero value to Address::ZERO — should succeed (no transfer, no log). -#[tokio::test] -async fn test_zero_address_zero_value_succeeds() { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("zero_addr") - .with_to(Address::ZERO) - .with_value(U256::ZERO) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("zero_addr").expect(TxStatus::Success)) - .with_action(AssertTxLogs::new("zero_addr").expect_no_logs()) - .with_action(AssertTxTrace::new("zero_addr")) - .run() - .await - .expect("test_zero_address_zero_value_succeeds failed"); -} - -/// Test #26: Pre-Zero5 — value transfer to Address::ZERO is not rejected. -#[tokio::test] -async fn test_pre_zero5_zero_address_allowed() { - reth_tracing::init_test_tracing(); - - let chain_spec = localdev_with_hardforks(&[ - (ArcHardfork::Zero3, ForkCondition::Block(0)), - (ArcHardfork::Zero4, ForkCondition::Block(0)), - (ArcHardfork::Zero5, ForkCondition::Block(100)), - (ArcHardfork::Zero6, ForkCondition::Block(100)), - ]); - - let value = U256::from(1_000_000); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new().with_chain_spec(chain_spec)) - .with_action( - SendTransaction::new("zero_addr") - .with_to(Address::ZERO) - .with_value(value) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - // Pre-Zero5: zero-address value transfer should succeed - .with_action(AssertTxIncluded::new("zero_addr").expect(TxStatus::Success)) - .with_action(AssertTxTrace::new("zero_addr")) - .run() - .await - .expect("test_pre_zero5_zero_address_allowed failed"); -} diff --git a/crates/execution-e2e/tests/hardfork_transition.rs b/crates/execution-e2e/tests/hardfork_transition.rs deleted file mode 100644 index 576dbd4f..00000000 --- a/crates/execution-e2e/tests/hardfork_transition.rs +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Hardfork transition e2e tests for Arc Chain. -//! -//! These tests verify that block production works correctly across -//! hardfork boundaries for Zero4, Zero5, Zero6, and Zero7 hardforks. - -use arc_execution_config::hardforks::ArcHardfork; -use arc_execution_e2e::{ - actions::{AssertBlockNumber, AssertEthereumHardfork, AssertHardfork, ProduceBlocks}, - chainspec::localdev_with_hardforks, - ArcSetup, ArcTestBuilder, -}; -use eyre::Result; -use reth_chainspec::{EthereumHardfork, ForkCondition}; - -#[tokio::test] -async fn test_hardfork_active_at_genesis() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero3)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero4)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero6)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero7)) - .run() - .await -} - -/// Test multiple hardfork transitions in sequence. -#[tokio::test] -async fn test_sequential_hardfork_transitions() -> Result<()> { - reth_tracing::init_test_tracing(); - - let chain_spec = localdev_with_hardforks(&[ - (ArcHardfork::Zero3, ForkCondition::Block(2)), - (ArcHardfork::Zero4, ForkCondition::Block(4)), - (ArcHardfork::Zero5, ForkCondition::Block(6)), - (ArcHardfork::Zero6, ForkCondition::Block(8)), - (ArcHardfork::Zero7, ForkCondition::Block(10)), - ]); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new().with_chain_spec(chain_spec)) - // At genesis (block 0) - .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero3)) - .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero4)) - .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero5)) - .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero6)) - .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero7)) - // Produce block 1-2 - Zero3 activates - .with_action(ProduceBlocks::new(2)) - .with_action(AssertBlockNumber::new(2)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero3)) - // Produce block 3-4 - Zero4 activates - .with_action(ProduceBlocks::new(2)) - .with_action(AssertBlockNumber::new(4)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero4)) - // Produce block 5-6 - Zero5 activates - .with_action(ProduceBlocks::new(2)) - .with_action(AssertBlockNumber::new(6)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) - // Produce block 7-8 - Zero6 activates - .with_action(ProduceBlocks::new(2)) - .with_action(AssertBlockNumber::new(8)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero6)) - // Produce block 9-10 - Zero7 activates - .with_action(ProduceBlocks::new(2)) - .with_action(AssertBlockNumber::new(10)) - .with_action(AssertHardfork::is_active(ArcHardfork::Zero7)) - .run() - .await -} - -/// Test that Osaka (Fusaka) hardfork is active on localdev and blocks produce correctly. -/// -/// Osaka is a timestamp-based Ethereum hardfork that enables EIP-7212 (P256 precompile), -/// EIP-7934 (RLP block size limit), and other Fusaka EIPs. -#[tokio::test] -async fn test_osaka_active_on_localdev() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - // Verify Osaka is active at genesis on localdev - .with_action(AssertEthereumHardfork::is_active(EthereumHardfork::Osaka)) - // Produce multiple blocks to confirm block production works with Osaka rules - .with_action(ProduceBlocks::new(3)) - .with_action(AssertBlockNumber::new(3)) - // Osaka should still be active after producing blocks - .with_action(AssertEthereumHardfork::is_active(EthereumHardfork::Osaka)) - .run() - .await -} diff --git a/crates/execution-e2e/tests/helpers/constants.rs b/crates/execution-e2e/tests/helpers/constants.rs deleted file mode 100644 index c44d602b..00000000 --- a/crates/execution-e2e/tests/helpers/constants.rs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Shared constants for EIP-7708 e2e tests. -//! -//! Each test binary only uses a subset of these; unused items are expected. -#![allow(dead_code)] - -use alloy_primitives::{address, Address}; - -// Re-export event signatures from the library to avoid duplication. -// Not all test files use both signatures, but they are shared constants. -#[allow(unused_imports)] -pub use arc_execution_e2e::actions::{NATIVE_COIN_TRANSFERRED_SIGNATURE, TRANSFER_EVENT_SIGNATURE}; - -/// EIP-7708 system address — emitter of Transfer logs under Zero5. -pub const SYSTEM_ADDRESS: Address = address!("0xfffffffffffffffffffffffffffffffffffffffe"); - -/// NativeCoinAuthority precompile — emitter of NativeCoinTransferred logs before Zero5. -pub const NATIVE_COIN_AUTHORITY_ADDRESS: Address = - address!("0x1800000000000000000000000000000000000000"); - -/// First account from test mnemonic (0xf39Fd...), funded in localdev genesis. -pub const WALLET_FIRST_ADDRESS: Address = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); diff --git a/crates/execution-e2e/tests/invalid_tx_list.rs b/crates/execution-e2e/tests/invalid_tx_list.rs deleted file mode 100644 index 4e87432e..00000000 --- a/crates/execution-e2e/tests/invalid_tx_list.rs +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! E2E tests for the invalid_tx_list functionality. -//! -//! The invalid_tx_list is an LRU cache that stores transaction hashes of transactions that -//! caused payload builder failures. This enables fast rejection during validation pre-check -//! to avoid repeatedly attempting to build blocks with problematic transactions. -//! -//! Key behavior: -//! - Unprocessable transactions (wrapped as `UnprocessableTransactionError`) are added to the cache -//! - When the payload builder panics, all pending transactions are added to the cache -//! - Cached transactions are rejected during validation pre-check with InvalidTxError -//! - LRU eviction removes oldest entries when capacity is exceeded -//! -//! Test coverage: -//! - Basic functionality: cache miss allows validation -//! - Disabled invalid_tx_list falls through to full validation -//! - Payload builder panic populates cache and resubmission is rejected - -use arc_execution_e2e::{ - actions::{AssertTxIncluded, ProduceBlocks, SendTransaction, TxStatus}, - ArcSetup, ArcTestBuilder, -}; -use arc_execution_txpool::InvalidTxListConfig; -use eyre::Result; -use rstest::rstest; - -/// Verifies that transactions not in the invalid_tx_list go through full validation -/// and are included in blocks across different configurations: -/// - Enabled with small/large capacity -/// - Disabled (falls through to full validation) -/// - Multiple independent transactions in a single block -#[rstest] -#[case::enabled(true, 1000, 1)] -#[case::disabled(false, 0, 1)] -#[case::large_capacity(true, 100_000, 1)] -#[case::multiple_txs(true, 1000, 3)] -#[tokio::test] -async fn test_normal_tx_processing( - #[case] enabled: bool, - #[case] capacity: u32, - #[case] num_txs: usize, -) -> Result<()> { - reth_tracing::init_test_tracing(); - - let mut builder = ArcTestBuilder::new().with_setup( - ArcSetup::new().with_invalid_tx_list_config(InvalidTxListConfig { enabled, capacity }), - ); - - let tx_names: Vec = (1..=num_txs).map(|i| format!("tx{i}")).collect(); - for name in &tx_names { - builder = builder.with_action(SendTransaction::new(name)); - } - - builder = builder.with_action(ProduceBlocks::new(1)); - - for name in &tx_names { - builder = builder.with_action(AssertTxIncluded::new(name).expect(TxStatus::Success)); - } - - builder.run().await -} - -/// Payload Builder Panic Populates Invalid TX List and Resubmission is Rejected -/// -/// Replicates the production flow when a single transaction causes a panic during execution: -/// 1. Submit two transactions: one valid transfer, one targeting a panicking precompile -/// 2. Trigger payload building — the per-transaction `catch_unwind` (payload.rs:589-622) -/// catches the panic and wraps it as `UnprocessableTransactionError`. The outer -/// `handle_build_res` calls `purge_unprocessable_tx`, which removes only the -/// offending transaction from the pool and adds it to the invalid_tx_list. -/// The valid transaction remains in the pool. -/// 3. Produce a subsequent block — succeeds (valid tx is included) -/// 4. Resubmit the panicking transaction — rejected with InvalidTxError -#[cfg(feature = "integration")] -#[tokio::test] -async fn test_payload_builder_panic_populates_invalid_tx_list() -> Result<()> { - use alloy_primitives::U256; - use arc_execution_e2e::ArcEnvironment; - use arc_execution_txpool::ArcTransactionValidatorError; - use arc_precompiles::precompile_provider::PANIC_PRECOMPILE_ADDRESS; - use reth_transaction_pool::error::{PoolError, PoolErrorKind}; - use reth_transaction_pool::{TransactionOrigin, TransactionPool}; - - reth_tracing::init_test_tracing(); - - let mut env = ArcEnvironment::new(); - ArcSetup::new() - .with_invalid_tx_list_config(InvalidTxListConfig { - enabled: true, - capacity: 1000, - }) - .apply(&mut env) - .await?; - - // Step 1: Submit two transactions, one valid, and one targeting the panicking precompile - let (good_tx_hash, _) = SendTransaction::new("good_tx") - .execute_and_return(&mut env) - .await?; - - let (panicking_tx_hash, panicking_tx) = SendTransaction::new("panic_tx") - .with_to(PANIC_PRECOMPILE_ADDRESS) - .with_value(U256::ZERO) - .with_gas_limit(100_000) - .execute_and_return(&mut env) - .await?; - - // Step 2: Attempt to produce a block. The payload builder executes both txs. - // The panicking precompile triggers a panic caught by the per-transaction - // catch_unwind, which wraps it as UnprocessableTransactionError. Only the - // offending tx is purged from the pool and added to invalid_tx_list. - // The build itself fails, but the side effect is what we're testing. - let mut produce = ProduceBlocks::new(1); - let result = arc_execution_e2e::Action::execute(&mut produce, &mut env).await; - assert!( - result.is_err(), - "Expected payload building to fail after panic" - ); - - // Assert the panicking tx was purged from the pool - let pool_size = env.node().inner.pool.len(); - assert_eq!( - pool_size, 1, - "Pool should have one transaction after panicking tx is purged" - ); - - assert!( - env.node().inner.pool.contains(&good_tx_hash), - "Good tx should be in the pool" - ); - - assert!( - !env.node().inner.pool.contains(&panicking_tx_hash), - "Panicking tx should not be in the pool" - ); - - // Step 3: Produce a block — succeeds now that the panicking tx has been purged - let mut produce_after = ProduceBlocks::new(1); - arc_execution_e2e::Action::execute(&mut produce_after, &mut env).await?; - - // Step 4: Resubmit the panicking transaction — should be rejected by invalid_tx_list - let result = env - .node() - .inner - .pool - .add_consensus_transaction(panicking_tx, TransactionOrigin::Local) - .await; - - match result { - Err(PoolError { - kind: PoolErrorKind::InvalidTransaction(ref e), - .. - }) => { - let arc_err = e - .downcast_other_ref::() - .expect("Expected ArcTransactionValidatorError"); - assert!( - matches!(arc_err, ArcTransactionValidatorError::InvalidTxError), - "Expected InvalidTxError, got: {arc_err:?}" - ); - Ok(()) - } - Ok(_) => Err(eyre::eyre!( - "Transaction {panicking_tx_hash} accepted on resubmission, expected rejection" - )), - Err(e) => Err(eyre::eyre!( - "Transaction {panicking_tx_hash} rejected with unexpected error: {e:?}" - )), - } -} - -/// Default-on regression: with no explicit `with_invalid_tx_list_config`, the -/// `InvalidTxListConfig::default()` must still quarantine a tx that triggers -/// `UnprocessableTransactionError` on resubmission. -#[cfg(feature = "integration")] -#[tokio::test] -async fn test_invalid_tx_list_default_on_quarantines_panicking_tx() -> Result<()> { - use alloy_primitives::U256; - use arc_execution_e2e::ArcEnvironment; - use arc_execution_txpool::ArcTransactionValidatorError; - use arc_precompiles::precompile_provider::PANIC_PRECOMPILE_ADDRESS; - use reth_transaction_pool::error::{PoolError, PoolErrorKind}; - use reth_transaction_pool::{TransactionOrigin, TransactionPool}; - - reth_tracing::init_test_tracing(); - - let mut env = ArcEnvironment::new(); - ArcSetup::new().apply(&mut env).await?; - - SendTransaction::new("good_tx") - .execute_and_return(&mut env) - .await?; - - let (panicking_tx_hash, panicking_tx) = SendTransaction::new("panic_tx") - .with_to(PANIC_PRECOMPILE_ADDRESS) - .with_value(U256::ZERO) - .with_gas_limit(100_000) - .execute_and_return(&mut env) - .await?; - - let mut produce = ProduceBlocks::new(1); - let _ = arc_execution_e2e::Action::execute(&mut produce, &mut env).await; - - let result = env - .node() - .inner - .pool - .add_consensus_transaction(panicking_tx, TransactionOrigin::Local) - .await; - - match result { - Err(PoolError { - kind: PoolErrorKind::InvalidTransaction(ref e), - .. - }) => { - let arc_err = e - .downcast_other_ref::() - .expect("Expected ArcTransactionValidatorError"); - assert!( - matches!(arc_err, ArcTransactionValidatorError::InvalidTxError), - "Expected InvalidTxError, got: {arc_err:?}" - ); - Ok(()) - } - Ok(_) => Err(eyre::eyre!( - "Transaction {panicking_tx_hash} accepted on resubmission, expected rejection under default config" - )), - Err(e) => Err(eyre::eyre!( - "Transaction {panicking_tx_hash} rejected with unexpected error: {e:?}" - )), - } -} diff --git a/crates/execution-e2e/tests/native_transfer_balance.rs b/crates/execution-e2e/tests/native_transfer_balance.rs deleted file mode 100644 index 2b4a9b4f..00000000 --- a/crates/execution-e2e/tests/native_transfer_balance.rs +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] - -//! E2E tests verifying that native value transfers produce correct balance changes. - -use alloy_primitives::{address, Address, U256}; -use arc_execution_e2e::{ - actions::{AssertBalance, AssertTxIncluded, ProduceBlocks, SendTransaction, TxStatus}, - ArcSetup, ArcTestBuilder, -}; -use eyre::Result; - -/// Genesis-funded sender address (hardhat account #0). -const SENDER: Address = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); - -/// Recipient with zero balance in genesis. -const RECIPIENT: Address = address!("0x000000000000000000000000000000000000bEEF"); - -/// Sender's genesis balance: 1,000,000 USDC (1_000_000e18 wei). -/// 0xd3c21bcecceda1000000 = limbs [0x1bcecceda1000000, 0xd3c2, 0, 0] -const SENDER_GENESIS_BALANCE: U256 = U256::from_limbs([0x1bce_cced_a100_0000, 0xd3c2, 0, 0]); - -/// Transfer value used in tests: 100 USDC (100e18 wei). -fn transfer_value() -> U256 { - U256::from(100u64) * U256::from(10u64).pow(U256::from(18u64)) -} - -/// Max gas cost per tx: gas_limit(21_000) * max_fee_per_gas(1000e9). -fn max_gas_cost() -> U256 { - U256::from(21_000u64) * U256::from(1_000_000_000_000u64) -} - -/// Recipient balance goes from 0 to the transferred value. -#[tokio::test] -async fn test_value_transfer_credits_recipient() -> Result<()> { - reth_tracing::init_test_tracing(); - - let value = transfer_value(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(RECIPIENT) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action(AssertBalance::new(RECIPIENT, value)) - .run() - .await -} - -/// Sender balance decreases by at least the transferred value (plus gas). -#[tokio::test] -async fn test_value_transfer_debits_sender() -> Result<()> { - reth_tracing::init_test_tracing(); - - let value = transfer_value(); - let min_remaining = SENDER_GENESIS_BALANCE - value - max_gas_cost(); - let max_remaining = SENDER_GENESIS_BALANCE - value; - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("transfer") - .with_to(RECIPIENT) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("transfer").expect(TxStatus::Success)) - .with_action( - AssertBalance::new(SENDER, min_remaining) - .at_least() - .at_most(max_remaining), - ) - .run() - .await -} - -/// Zero-value transfer leaves recipient balance unchanged. -#[tokio::test] -async fn test_zero_value_transfer_no_balance_change() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("zero_transfer") - .with_to(RECIPIENT) - .with_value(U256::ZERO), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("zero_transfer").expect(TxStatus::Success)) - .with_action(AssertBalance::new(RECIPIENT, U256::ZERO)) - .run() - .await -} - -/// Two transfers to the same recipient accumulate. -#[tokio::test] -async fn test_multiple_transfers_accumulate() -> Result<()> { - reth_tracing::init_test_tracing(); - - let value = transfer_value(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("tx1") - .with_to(RECIPIENT) - .with_value(value), - ) - .with_action( - SendTransaction::new("tx2") - .with_to(RECIPIENT) - .with_value(value), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("tx1").expect(TxStatus::Success)) - .with_action(AssertTxIncluded::new("tx2").expect(TxStatus::Success)) - .with_action(AssertBalance::new(RECIPIENT, value + value)) - .run() - .await -} diff --git a/crates/execution-e2e/tests/transaction.rs b/crates/execution-e2e/tests/transaction.rs deleted file mode 100644 index 155894fb..00000000 --- a/crates/execution-e2e/tests/transaction.rs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2026 Circle Internet Group, Inc. All rights reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Transaction sending e2e tests for Arc Chain. - -use alloy_primitives::{address, bytes, U256}; -use arc_execution_e2e::{ - actions::{AssertTxIncluded, ProduceBlocks, SendTransaction, TxStatus}, - ArcSetup, ArcTestBuilder, -}; -use eyre::Result; - -/// Test sending multiple transactions in a single block. -#[tokio::test] -async fn test_multiple_transactions() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action(SendTransaction::new("tx1")) - .with_action(SendTransaction::new("tx2")) - .with_action(ProduceBlocks::new(1)) - .with_action(SendTransaction::new("tx3")) - .with_action(ProduceBlocks::new(1)) - .with_action( - AssertTxIncluded::new("tx1") - .in_block(1) - .expect(TxStatus::Success), - ) - .with_action( - AssertTxIncluded::new("tx2") - .in_block(1) - .expect(TxStatus::Success), - ) - .with_action( - AssertTxIncluded::new("tx3") - .in_block(2) - .expect(TxStatus::Success), - ) - .run() - .await -} - -/// Test that a contract call that reverts is detected. -#[tokio::test] -async fn test_reverted_transaction() -> Result<()> { - reth_tracing::init_test_tracing(); - - ArcTestBuilder::new() - .with_setup(ArcSetup::new()) - .with_action( - SendTransaction::new("tx1") - .with_to(address!("0x3600000000000000000000000000000000000000")) - .with_value(U256::ZERO) // Value must be 0 — FiatTokenProxy (0x3600…0000) is pre-blocklisted in NativeCoinControl - .with_data(bytes!("0x1234abcd")) - .with_gas_limit(100_000), - ) - .with_action(ProduceBlocks::new(1)) - .with_action(AssertTxIncluded::new("tx1").expect(TxStatus::Reverted)) - .run() - .await -} diff --git a/crates/execution-payload/Cargo.toml b/crates/execution-payload/Cargo.toml index 4bb931e2..96b88a6d 100644 --- a/crates/execution-payload/Cargo.toml +++ b/crates/execution-payload/Cargo.toml @@ -18,6 +18,7 @@ alloy-consensus.workspace = true alloy-primitives.workspace = true alloy-rlp.workspace = true arc-execution-txpool.workspace = true +arc-precompiles.workspace = true eyre.workspace = true metrics.workspace = true @@ -26,11 +27,12 @@ reth-basic-payload-builder.workspace = true reth-chainspec.workspace = true reth-consensus-common.workspace = true reth-errors.workspace = true -reth-ethereum = { workspace = true, features = ["storage-api"] } +reth-ethereum = { workspace = true, features = ["storage-api", "trie"] } reth-ethereum-engine-primitives.workspace = true reth-ethereum-payload-builder.workspace = true reth-ethereum-primitives.workspace = true reth-evm.workspace = true +reth-execution-cache.workspace = true reth-node-api.workspace = true reth-node-builder.workspace = true reth-payload-builder.workspace = true @@ -39,12 +41,15 @@ reth-primitives-traits.workspace = true reth-revm.workspace = true reth-storage-api.workspace = true reth-transaction-pool.workspace = true +reth-trie-parallel.workspace = true # revm revm.workspace = true tracing.workspace = true [dev-dependencies] +reth-evm-ethereum.workspace = true +reth-provider = { workspace = true, features = ["test-utils"] } reth-transaction-pool = { workspace = true, features = ["test-utils"] } tokio = { workspace = true, features = ["full"] } tracing-test = "0.2" diff --git a/crates/execution-payload/src/payload.rs b/crates/execution-payload/src/payload.rs index ebd6bb39..e6fbc3de 100644 --- a/crates/execution-payload/src/payload.rs +++ b/crates/execution-payload/src/payload.rs @@ -22,7 +22,7 @@ //! `arc_ethereum_payload` and converted to `UnprocessableTransactionError`. use alloy_primitives::U256; -use alloy_primitives::{hex, TxHash}; +use alloy_primitives::{hex, TxHash, B256}; use alloy_rlp::Encodable; use eyre::Result; use reth_basic_payload_builder::{ @@ -32,25 +32,32 @@ use reth_basic_payload_builder::{ use reth_chainspec::{ChainSpecProvider, EthChainSpec, EthereumHardforks}; use reth_consensus_common::validation::MAX_RLP_BLOCK_SIZE; use reth_errors::{BlockExecutionError, BlockValidationError, ConsensusError}; +use reth_ethereum::trie::updates::TrieUpdates; +use reth_ethereum_engine_primitives::EthPayloadAttributes; use reth_ethereum_payload_builder::EthereumBuilderConfig; use reth_ethereum_primitives::{EthPrimitives, TransactionSigned}; use reth_evm::{ - execute::{BlockBuilder, BlockBuilderOutcome}, + execute::{BlockBuilder, BlockBuilderOutcome, BlockExecutor}, ConfigureEvm, Evm, NextBlockEnvAttributes, }; +use reth_execution_cache::{ + CachedStateMetrics, CachedStateMetricsSource, CachedStateProvider, SavedCache, +}; use reth_node_api::{NodeTypes, PrimitivesTy}; use reth_node_builder::{ components::PayloadBuilderBuilder, node::FullNodeTypes, BuilderContext, PayloadBuilderConfig, }; -use reth_payload_builder::{BlobSidecars, EthBuiltPayload, EthPayloadBuilderAttributes}; -use reth_payload_primitives::{PayloadBuilderAttributes, PayloadBuilderError}; +use reth_payload_builder::{BlobSidecars, EthBuiltPayload}; +use reth_payload_primitives::PayloadBuilderError; use reth_primitives_traits::transaction::error::InvalidTransactionError; use reth_revm::{database::StateProviderDatabase, db::State}; -use reth_storage_api::StateProviderFactory; +use reth_storage_api::{StateProviderBox, StateProviderFactory}; use reth_transaction_pool::{ error::InvalidPoolTransactionError, BestTransactions, BestTransactionsAttributes, PoolTransaction, TransactionPool, ValidPoolTransaction, }; +use reth_trie_parallel::state_root_task::StateRootHandle; +use revm::context_interface::result::InvalidTransaction; use revm::context_interface::Block as _; use std::{ panic::{catch_unwind, AssertUnwindSafe}, @@ -62,6 +69,7 @@ use tracing::{debug, error, info, trace, warn}; use crate::builder::UnprocessableTransactionError; use crate::metrics::PayloadBuildMetrics; use arc_execution_txpool::InvalidTxList; +use arc_precompiles::helpers::ERR_BLOCKED_ADDRESS; type BestTransactionsIter = Box< dyn BestTransactions::Transaction>>>, @@ -109,7 +117,6 @@ where ::Payload: reth_node_api::PayloadTypes< BuiltPayload = EthBuiltPayload, PayloadAttributes = reth_ethereum_engine_primitives::EthPayloadAttributes, - PayloadBuilderAttributes = EthPayloadBuilderAttributes, >, { type PayloadBuilder = InvalidTxFilteringPayloadBuilder< @@ -219,12 +226,8 @@ fn purge_unprocessable_tx( error!(tx_hash = %tx_hash, "unprocessable transaction not found in pool"); } - if let Some(invalid_tx_list) = invalid_tx_list { - error!(tx_hash = %tx_hash, "adding unprocessable transaction to invalid tx list"); - add_pending_txs_to_invalid_list(pool, invalid_tx_list, vec![tx_hash]); - } else { - error!(tx_hash = %tx_hash, "invalid tx list is disabled, cannot add unprocessable transaction"); - } + error!(tx_hash = %tx_hash, quarantined = invalid_tx_list.is_some(), "evicting unprocessable transaction from pool"); + evict_unincludable_txs(pool, invalid_tx_list, vec![tx_hash]); } } @@ -242,12 +245,11 @@ fn purge_pending_and_resume_panic( .map(|tx| *tx.hash()) .collect(); - if let Some(invalid_tx_list) = invalid_tx_list { - error!("payload builder panicked, adding all PENDING TXs to invalid tx list"); - add_pending_txs_to_invalid_list(pool, invalid_tx_list, pending_hashes); - } else { - error!("payload builder panicked, but invalid tx list disabled"); - } + error!( + quarantined = invalid_tx_list.is_some(), + "payload builder panicked, evicting all PENDING TXs from pool" + ); + evict_unincludable_txs(pool, invalid_tx_list, pending_hashes); std::panic::resume_unwind(panic) } @@ -312,7 +314,7 @@ fn extract_unprocessable_tx_hash(err: &PayloadBuilderError) -> Option { } } -/// Introduced to improve testability of `add_pending_txs_to_invalid_list` +/// Introduced to improve testability of `evict_unincludable_txs` trait PendingPool { fn remove_transactions_and_descendants(&self, hashes: Vec) -> usize; fn pending_len(&self) -> usize; @@ -327,27 +329,48 @@ impl PendingPool for T { } } -fn add_pending_txs_to_invalid_list( +/// Evicts permanently un-includable transactions. +/// +/// Pool removal is unconditional — it frees the slot and unblocks the sender's nonce, which +/// is the remediation and must happen regardless of whether the invalid-tx-list +/// feature is enabled. Quarantining the hash in the `InvalidTxList` is an +/// optional re-gossip suppression on top, applied only when a list is configured. +fn evict_unincludable_txs( pool: &P, - invalid_tx_list: &InvalidTxList, + invalid_tx_list: Option<&InvalidTxList>, hashes: Vec, ) { let before = pool.pending_len(); if hashes.is_empty() { - error!("add_pending_txs_to_invalid_list: no pending transactions to add"); + error!("evict_unincludable_txs: no transactions to evict"); } - invalid_tx_list.insert_many(hashes.iter().copied()); + if let Some(invalid_tx_list) = invalid_tx_list { + invalid_tx_list.insert_many(hashes.iter().copied()); + } let removed = pool.remove_transactions_and_descendants(hashes); warn!( removed, pending_before = before, pending_after = pool.pending_len(), - "added pending txs to invalid tx list" + quarantined = invalid_tx_list.is_some(), + "evicted unincludable txs from pool" ); } +/// True when a tx can never be included under the current block gas limit (as opposed to +/// merely not fitting this block's remaining gas, which can be retried in a fresh block). +fn exceeds_block_gas_limit_permanently(tx_gas_limit: u64, block_gas_limit: u64) -> bool { + tx_gas_limit > block_gas_limit +} + +/// True when an EVM rejection is a blocklist hit. A blocklisted address can never produce an +/// includable tx until it is unblocklisted, so such a tx is permanently un-includable. +fn is_blocked_address_error(err: Option<&InvalidTransaction>) -> bool { + matches!(err, Some(InvalidTransaction::Str(msg)) if msg.as_ref() == ERR_BLOCKED_ADDRESS) +} + /// Format TX data as a multi-line hexdump if too long. fn dump_tx_data(bytes: &[u8]) -> String { const INLINE_LIMIT_BYTES: usize = 512; @@ -422,12 +445,12 @@ where Client: StateProviderFactory + ChainSpecProvider + Clone, Pool: TransactionPool>, { - type Attributes = EthPayloadBuilderAttributes; + type Attributes = EthPayloadAttributes; type BuiltPayload = EthBuiltPayload; fn try_build( &self, - args: BuildArguments, + args: BuildArguments, ) -> Result, PayloadBuilderError> { arc_ethereum_payload( self.evm_config.clone(), @@ -455,9 +478,16 @@ where fn build_empty_payload( &self, - config: PayloadConfig, + config: PayloadConfig>, ) -> Result { - let args = BuildArguments::new(Default::default(), config, Default::default(), None); + let args = BuildArguments::new( + Default::default(), + Default::default(), + None, + config, + Default::default(), + None, + ); // This is what's done in upstream EthereumPayloadBuilder::build_empty_payload arc_ethereum_payload( @@ -489,6 +519,137 @@ fn proposer_revenue(tx: &T, gas_used: u64, base } } +/// Outcome of running a single transaction in the payload-building loop. +enum TxOutcome { + /// Transaction executed successfully — record `gas_used`, advance. + Included(u64), + /// Skip this tx silently (e.g. nonce too low). Caller continues. + Skip, + /// Skip and mark the tx invalid in the pool so descendants are evicted. + SkipAndMarkInvalid, + /// Tx was rejected because its sender or recipient is blocklisted. Skip, mark + /// invalid, and evict from the pool — a blocklisted address is permanently + /// un-includable until unblocklisted. + SkipMarkInvalidAndEvictBlocked, + /// Tx gas limit exceeds the gas remaining in the block. Skip and mark invalid + /// with the executor's reported limits so descendants are evicted. + SkipExceedsGasLimit { + transaction_gas_limit: u64, + block_available_gas: u64, + }, + /// Unrecoverable error for this build attempt — propagate. + Fatal(PayloadBuilderError), +} + +/// Classifies the result of `catch_unwind(|| builder.execute_transaction(...))` +/// into one of six loop-control outcomes. The caller is responsible for invoking +/// `best_txs.mark_invalid(...)` (and pool eviction) on the `SkipAndMarkInvalid`, +/// `SkipMarkInvalidAndEvictBlocked`, and `SkipExceedsGasLimit` arms — kept out of +/// this helper so the signature stays non-generic. +fn classify_tx_outcome( + result: std::thread::Result>, + tx_hash: TxHash, + tx: &TransactionSigned, +) -> TxOutcome { + match result { + Ok(Ok(gas_used)) => TxOutcome::Included(gas_used), + Ok(Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx { + error, .. + }))) => { + if error.is_nonce_too_low() { + trace!(target: "payload_builder", %error, ?tx, "(arc) skipping nonce too low transaction"); + TxOutcome::Skip + } else if is_blocked_address_error(error.as_invalid_tx_err()) { + trace!(target: "payload_builder", %error, ?tx, "(arc) evicting blocklisted transaction and its descendants"); + TxOutcome::SkipMarkInvalidAndEvictBlocked + } else { + trace!(target: "payload_builder", %error, ?tx, "(arc) skipping invalid transaction and its descendants"); + TxOutcome::SkipAndMarkInvalid + } + } + // The executor is the source of truth for block gas availability. Keep this + // non-fatal in case local builder accounting diverges from executor rules. + Ok(Err(BlockExecutionError::Validation( + BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas { + transaction_gas_limit, + block_available_gas, + }, + ))) => { + trace!(target: "payload_builder", %transaction_gas_limit, %block_available_gas, ?tx, "(arc) skipping transaction exceeding block gas limit"); + TxOutcome::SkipExceedsGasLimit { + transaction_gas_limit, + block_available_gas, + } + } + Ok(Err(err)) => TxOutcome::Fatal(PayloadBuilderError::evm(err)), + Err(_panic_payload) => { + TxOutcome::Fatal(PayloadBuilderError::other(UnprocessableTransactionError { + tx_hash, + })) + } + } +} + +/// True when an optional time budget has been exceeded. +fn time_budget_exhausted(started: Instant, limit: Option) -> bool { + limit.is_some_and(|l| started.elapsed() >= l) +} + +/// True when the Osaka hardfork is active AND the candidate block size +/// exceeds [`MAX_RLP_BLOCK_SIZE`]. +fn osaka_size_exceeded(is_osaka: bool, size: usize) -> bool { + is_osaka && size > MAX_RLP_BLOCK_SIZE +} + +/// Returns `state_provider` wrapped in a [`CachedStateProvider`] when an +/// execution cache is present, otherwise returns it unwrapped. Centralises +/// the conditional so the caller stays free of control flow. +fn maybe_wrap_with_execution_cache( + state_provider: StateProviderBox, + execution_cache: Option, +) -> StateProviderBox { + if let Some(cache) = execution_cache { + // reth 2.2 removed `SavedCache::metrics()`, so we construct fresh builder-sourced + // metrics per build, matching the default payload builder. Per-cache hit/miss + // continuity is lost; global Prometheus aggregates are unaffected because the + // metric handles are shared. + Box::new(CachedStateProvider::new( + state_provider, + cache.cache().clone(), + CachedStateMetrics::zeroed(CachedStateMetricsSource::Builder), + )) + } else { + state_provider + } +} + +/// Drives the sparse-trie state-root task to completion, falling back to +/// sync state-root computation (returns `None`) on failure. Returns the +/// precomputed `(state_root, trie_updates)` pair to hand to +/// `BlockBuilder::finish` when the parallel result is usable. +/// +/// Caller must clear the builder's state hook before invoking — the helper +/// stays non-generic by keeping the builder out of its signature. +fn try_precomputed_state_root( + trie_handle: Option, + payload_id: &impl std::fmt::Display, +) -> Option<(B256, TrieUpdates)> { + let mut handle = trie_handle?; + match handle.state_root() { + Ok(outcome) => { + debug!(target: "payload_builder", id=%payload_id, state_root=?outcome.state_root, "(arc) received state root from sparse trie"); + Some(( + outcome.state_root, + Arc::unwrap_or_clone(outcome.trie_updates), + )) + } + Err(err) => { + warn!(target: "payload_builder", id=%payload_id, %err, "(arc) sparse trie failed, falling back to sync state root"); + None + } + } +} + /// Constructs an transaction payload using the best transactions from the pool. /// It follows the upstream Ethereum payload building logic with a Arc-specific deadline for the main loop. /// @@ -497,13 +658,14 @@ fn proposer_revenue(tx: &T, gas_used: u64, base /// and configuration, this function creates a transaction payload. Returns /// a result indicating success with the payload or an error in case of failure. #[inline] +#[allow(clippy::too_many_arguments)] pub fn arc_ethereum_payload( evm_config: EvmConfig, client: Client, - _pool: Pool, + pool: Pool, builder_config: EthereumBuilderConfig, loop_time_limit: Option, - args: BuildArguments, + args: BuildArguments, best_txs: F, ) -> Result, PayloadBuilderError> where @@ -514,6 +676,8 @@ where { let BuildArguments { mut cached_reads, + execution_cache, + trie_handle, config, cancel, best_payload, @@ -521,12 +685,16 @@ where let PayloadConfig { parent_header, attributes, + payload_id, } = config; let total_start = Instant::now(); let stage_start = Instant::now(); - let state_provider = client.state_by_block_hash(parent_header.hash())?; + let state_provider = maybe_wrap_with_execution_cache( + client.state_by_block_hash(parent_header.hash())?, + execution_cache, + ); let state = StateProviderDatabase::new(state_provider.as_ref()); let mut db = State::builder() .with_database(cached_reads.as_db_mut(state)) @@ -539,20 +707,21 @@ where &mut db, &parent_header, NextBlockEnvAttributes { - timestamp: attributes.timestamp(), - suggested_fee_recipient: attributes.suggested_fee_recipient(), - prev_randao: attributes.prev_randao(), + timestamp: attributes.timestamp, + suggested_fee_recipient: attributes.suggested_fee_recipient, + prev_randao: attributes.prev_randao, gas_limit: builder_config.gas_limit(parent_header.gas_limit), - parent_beacon_block_root: attributes.parent_beacon_block_root(), - withdrawals: Some(attributes.withdrawals().clone()), + parent_beacon_block_root: attributes.parent_beacon_block_root, + withdrawals: attributes.withdrawals.clone().map(Into::into), extra_data: builder_config.extra_data, + slot_number: None, }, ) .map_err(PayloadBuilderError::other)?; let chain_spec = client.chain_spec(); - info!(target: "payload_builder", id=%attributes.id, parent_header = ?parent_header.hash(), parent_number = parent_header.number, "(arc) building new payload"); + info!(target: "payload_builder", id=%payload_id, parent_header = ?parent_header.hash(), parent_number = parent_header.number, "(arc) building new payload"); let mut cumulative_gas_used = 0u64; let block_gas_limit: u64 = builder.evm_mut().block().gas_limit(); let base_fee = builder.evm_mut().block().basefee(); @@ -563,6 +732,12 @@ where )); let mut total_fees = U256::ZERO; + trie_handle.as_ref().inspect(|handle| { + builder + .executor_mut() + .set_state_hook(Some(Box::new(handle.state_hook()))); + }); + let stage_start = Instant::now(); builder.apply_pre_execution_changes().map_err(|err| { warn!(target: "payload_builder", %err, "(arc) failed to apply pre-execution changes"); @@ -573,19 +748,16 @@ where let mut block_transactions_rlp_length = 0usize; let is_osaka = chain_spec.is_osaka_active_at_timestamp(attributes.timestamp); - let withdrawals_rlp_length = attributes.withdrawals().length(); + let withdrawals_rlp_length = attributes.withdrawals.as_ref().map_or(0, |w| w.length()); let loop_started = Instant::now(); while let Some(pool_tx) = best_txs.next() { - // Break early if loop time budget exhausted - if let Some(limit) = loop_time_limit { - if loop_started.elapsed() >= limit { - #[allow(clippy::cast_possible_truncation)] - let elapsed_ms = loop_started.elapsed().as_millis() as u64; - warn!(elapsed_ms, "(arc) loop time budget reached; sealing early"); - break; - } + if time_budget_exhausted(loop_started, loop_time_limit) { + #[allow(clippy::cast_possible_truncation)] + let elapsed_ms = loop_started.elapsed().as_millis() as u64; + warn!(elapsed_ms, "(arc) loop time budget reached; sealing early"); + break; } // ensure we still have capacity for this transaction @@ -601,6 +773,15 @@ where &pool_tx, &InvalidPoolTransactionError::ExceedsGasLimit(pool_tx.gas_limit(), block_gas_limit), ); + // A tx whose gas limit exceeds the block gas limit can never be included in any + // block, so evict it from the pool instead of skipping it every build. This is a + // recoverable condition (the limit may be raised), so we only free the pool slot + // and leave re-admission to the validator's stateful gas-limit check rather than + // quarantining the hash in the invalid tx list. + if exceeds_block_gas_limit_permanently(pool_tx.gas_limit(), block_gas_limit) { + warn!(target: "payload_builder", tx_hash = %pool_tx.hash(), tx_gas_limit = pool_tx.gas_limit(), block_gas_limit, "(arc) evicting permanently un-includable transaction (exceeds block gas limit)"); + evict_unincludable_txs(&pool, None, vec![*pool_tx.hash()]); + } continue; } @@ -622,7 +803,7 @@ where .saturating_add(withdrawals_rlp_length) .saturating_add(1024); // 1Kb of overhead for the block header - if is_osaka && estimated_block_size_with_tx > MAX_RLP_BLOCK_SIZE { + if osaka_size_exceeded(is_osaka, estimated_block_size_with_tx) { best_txs.mark_invalid( &pool_tx, &InvalidPoolTransactionError::OversizedData { @@ -633,40 +814,55 @@ where continue; } - let gas_used = match catch_unwind(AssertUnwindSafe(|| { - builder.execute_transaction(tx.clone()) - })) { - Ok(Ok(gas_used)) => gas_used, - Ok(Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx { - error, - .. - }))) => { - if error.is_nonce_too_low() { - // if the nonce is too low, we can skip this transaction - trace!(target: "payload_builder", %error, ?tx, "(arc) skipping nonce too low transaction"); - } else { - // if the transaction is invalid, we can skip it and all of its - // descendants - trace!(target: "payload_builder", %error, ?tx, "(arc) skipping invalid transaction and its descendants"); - best_txs.mark_invalid( - &pool_tx, - &InvalidPoolTransactionError::Consensus( - InvalidTransactionError::TxTypeNotSupported, - ), - ); - } + let raw_result = catch_unwind(AssertUnwindSafe(|| { + builder + .execute_transaction(tx.clone()) + .map(|out| out.tx_gas_used()) + })); + let gas_used = match classify_tx_outcome(raw_result, *pool_tx.hash(), &tx) { + TxOutcome::Included(g) => g, + TxOutcome::Skip => continue, + TxOutcome::SkipAndMarkInvalid => { + best_txs.mark_invalid( + &pool_tx, + &InvalidPoolTransactionError::Consensus( + InvalidTransactionError::TxTypeNotSupported, + ), + ); + continue; + } + TxOutcome::SkipMarkInvalidAndEvictBlocked => { + best_txs.mark_invalid( + &pool_tx, + &InvalidPoolTransactionError::Consensus( + InvalidTransactionError::TxTypeNotSupported, + ), + ); + // A blocklisted address can never produce an includable tx until it is + // unblocklisted, so evict it from the pool instead of skipping it every + // build. This is a recoverable condition (the address may be + // unblocklisted), so we only free the pool slot and leave re-admission to + // the validator's stateful blocklist check rather than quarantining the + // hash in the invalid tx list. Other invalid errors (e.g. insufficient + // funds) may become valid later, so they keep the skip-only behavior above. + warn!(target: "payload_builder", tx_hash = %pool_tx.hash(), sender = %pool_tx.sender(), to = ?pool_tx.to(), "(arc) evicting permanently un-includable transaction (blocklisted address)"); + evict_unincludable_txs(&pool, None, vec![*pool_tx.hash()]); continue; } - // this is an error that we should treat as fatal for this attempt - Ok(Err(err)) => return Err(PayloadBuilderError::evm(err)), - // a single transaction caused a panic — wrap it so handle_build_res - // can identify the offending tx and purge it from the mempool - Err(_panic_payload) => { - let tx_hash = *pool_tx.hash(); - return Err(PayloadBuilderError::other(UnprocessableTransactionError { - tx_hash, - })); + TxOutcome::SkipExceedsGasLimit { + transaction_gas_limit, + block_available_gas, + } => { + best_txs.mark_invalid( + &pool_tx, + &InvalidPoolTransactionError::ExceedsGasLimit( + transaction_gas_limit, + block_available_gas, + ), + ); + continue; } + TxOutcome::Fatal(e) => return Err(e), }; block_transactions_rlp_length = block_transactions_rlp_length.saturating_add(tx_rlp_len); @@ -696,11 +892,15 @@ where } let builder_finish = Instant::now(); + // `set_state_hook(None)` is idempotent; call unconditionally so the + // sparse-trie hook (if any) is always cleared before block finalization. + builder.executor_mut().set_state_hook(None); + let precomputed = try_precomputed_state_root(trie_handle, &payload_id); let BlockBuilderOutcome { execution_result, block, .. - } = builder.finish(state_provider.as_ref())?; + } = builder.finish(state_provider.as_ref(), precomputed)?; PayloadBuildMetrics::record_stage_post_execution(builder_finish.elapsed()); let stage_start = Instant::now(); @@ -709,9 +909,9 @@ where .then_some(execution_result.requests); let sealed_block = Arc::new(block.sealed_block().clone()); - debug!(target: "payload_builder", id=%attributes.id, sealed_block_header = ?sealed_block.sealed_header(), "(arc) sealed built block"); + debug!(target: "payload_builder", id=%payload_id, sealed_block_header = ?sealed_block.sealed_header(), "(arc) sealed built block"); - if is_osaka && sealed_block.rlp_length() > MAX_RLP_BLOCK_SIZE { + if osaka_size_exceeded(is_osaka, sealed_block.rlp_length()) { PayloadBuildMetrics::record_stage_assembly_and_sealing(stage_start.elapsed()); PayloadBuildMetrics::record_total_duration(total_start); return Err(PayloadBuilderError::other(ConsensusError::BlockTooLarge { @@ -720,7 +920,7 @@ where })); } - let payload = EthBuiltPayload::new(attributes.id, sealed_block, total_fees, requests) + let payload = EthBuiltPayload::new(sealed_block, total_fees, requests, None) // add blob sidecars from the executed txs; empty for now .with_sidecars(BlobSidecars::Empty); PayloadBuildMetrics::record_stage_assembly_and_sealing(stage_start.elapsed()); @@ -766,11 +966,45 @@ mod tests { } #[test] - fn add_pending_txs_to_invalid_list_inserts_all() { + fn exceeds_block_gas_limit_permanently_classifies_correctly() { + // Strictly greater than the block limit: can never fit any block. + assert!(exceeds_block_gas_limit_permanently(30000001, 30000000)); + // Equal to or below the block limit: fits a fresh block, only temporarily skipped. + assert!(!exceeds_block_gas_limit_permanently(30000000, 30000000)); + assert!(!exceeds_block_gas_limit_permanently(21000, 30000000)); + } + + #[test] + fn is_blocked_address_error_only_matches_blocklist() { + // Blocklist rejection: permanent until unblocklisted -> evict. + let blocked = InvalidTransaction::Str(ERR_BLOCKED_ADDRESS.into()); + assert!(is_blocked_address_error(Some(&blocked))); + + // Other string errors must not be treated as blocklist hits. + let other = InvalidTransaction::Str("some other error".into()); + assert!(!is_blocked_address_error(Some(&other))); + + // Temporarily-invalid errors may become valid later -> keep. + assert!(!is_blocked_address_error(Some( + &InvalidTransaction::NonceTooLow { tx: 1, state: 2 } + ))); + assert!(!is_blocked_address_error(Some( + &InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(U256::from(1)), + balance: Box::new(U256::ZERO), + } + ))); + + // No invalid-tx error at all (e.g. a non-InvalidTransaction rejection). + assert!(!is_blocked_address_error(None)); + } + + #[test] + fn evict_unincludable_txs_inserts_all() { let hashes: Vec = (0..3).map(TxHash::repeat_byte).collect(); let pool = MockPendingPool::new(hashes.clone()); let invalid_tx_list = InvalidTxList::new(16); - add_pending_txs_to_invalid_list(&pool, &invalid_tx_list, hashes.clone()); + evict_unincludable_txs(&pool, Some(&invalid_tx_list), hashes.clone()); assert_eq!(hashes.len(), invalid_tx_list.len()); for h in hashes { assert!(invalid_tx_list.contains(&h)); @@ -778,19 +1012,19 @@ mod tests { } #[test] - fn add_pending_txs_to_invalid_list_empty_no_insert() { + fn evict_unincludable_txs_empty_no_insert() { let pool = MockPendingPool::new(vec![]); let invalid_tx_list = InvalidTxList::new(16); - add_pending_txs_to_invalid_list(&pool, &invalid_tx_list, vec![]); + evict_unincludable_txs(&pool, Some(&invalid_tx_list), vec![]); assert_eq!(0, invalid_tx_list.len()); } #[test] - fn add_pending_txs_to_invalid_list_removes_from_pool() { + fn evict_unincludable_txs_removes_from_pool() { let hashes: Vec = (0..5).map(TxHash::repeat_byte).collect(); let pool = MockPendingPool::new(hashes.clone()); let invalid_tx_list = InvalidTxList::new(64); - add_pending_txs_to_invalid_list(&pool, &invalid_tx_list, hashes.clone()); + evict_unincludable_txs(&pool, Some(&invalid_tx_list), hashes.clone()); assert_eq!(hashes.len(), invalid_tx_list.len()); for h in &hashes { assert!(invalid_tx_list.contains(h)); @@ -803,6 +1037,21 @@ mod tests { } } + #[test] + fn evict_unincludable_txs_removes_from_pool_when_list_disabled() { + // When the invalid tx list is disabled (None), pool removal must still happen — + // this is the remediation under `--invalid-tx-list-enable=false`. + let hashes: Vec = (0..4).map(TxHash::repeat_byte).collect(); + let pool = MockPendingPool::new(hashes.clone()); + evict_unincludable_txs(&pool, None, hashes.clone()); + + let removed = pool.removed.borrow().clone(); + assert_eq!(hashes.len(), removed.len()); + for h in &hashes { + assert!(removed.contains(h)); + } + } + #[test] fn extract_unprocessable_tx_hash_extracts_correctly() { let test_hash = TxHash::repeat_byte(0xCD); @@ -1018,20 +1267,20 @@ mod tests { impl MockInnerBuilder { fn build_payload() -> EthBuiltPayload { - use reth_payload_builder::{BlobSidecars, PayloadId}; + use reth_payload_builder::BlobSidecars; let block = reth_ethereum::Block { header: alloy_consensus::Header::default(), body: Default::default(), }; let sealed = reth_ethereum::primitives::SealedBlock::from(block); - EthBuiltPayload::new(PayloadId::new([0u8; 8]), Arc::new(sealed), U256::ZERO, None) + EthBuiltPayload::new(Arc::new(sealed), U256::ZERO, None, None) .with_sidecars(BlobSidecars::Empty) } } impl RethPayloadBuilder for MockInnerBuilder { - type Attributes = EthPayloadBuilderAttributes; + type Attributes = EthPayloadAttributes; type BuiltPayload = EthBuiltPayload; fn try_build( @@ -1057,20 +1306,20 @@ mod tests { } } - fn empty_payload_config() -> PayloadConfig { - let attributes = EthPayloadBuilderAttributes::new( - Default::default(), - reth_ethereum_engine_primitives::EthPayloadAttributes { - timestamp: 1, - prev_randao: Default::default(), - suggested_fee_recipient: Default::default(), - withdrawals: Some(vec![]), - parent_beacon_block_root: Some(Default::default()), - }, - ); + fn empty_payload_config( + ) -> PayloadConfig> { + let attributes = EthPayloadAttributes { + timestamp: 1, + prev_randao: Default::default(), + suggested_fee_recipient: Default::default(), + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(Default::default()), + slot_number: None, + }; PayloadConfig { parent_header: Arc::new(reth_ethereum::primitives::SealedHeader::default()), attributes, + payload_id: reth_payload_builder::PayloadId::new([0u8; 8]), } } @@ -1228,4 +1477,386 @@ mod tests { let expected = U256::from(gas_price) * U256::from(gas_used); assert_eq!(proposer_revenue(&tx, gas_used, base_fee), expected); } + + // A tx whose gas limit exceeds the gas remaining in the block must skip and + // mark-invalid (matching the default builder), never abort the build. Before + // reth 2.2 this error fell into the catch-all `Fatal` arm, which would have + // killed payload production for one oversized pool tx. + #[test] + fn classify_gas_limit_exceeded_skips_with_executor_limits() { + use alloy_consensus::{Signed, TxEip1559}; + use alloy_primitives::{Address, Signature, TxKind}; + + let tx = TxEip1559 { + chain_id: 1, + nonce: 0, + gas_limit: 30_000_000, + max_fee_per_gas: 1, + max_priority_fee_per_gas: 0, + to: TxKind::Call(Address::ZERO), + value: U256::ZERO, + access_list: Default::default(), + input: Default::default(), + }; + let signed: TransactionSigned = + Signed::new_unhashed(tx, Signature::test_signature()).into(); + + let err = BlockExecutionError::Validation( + BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas { + transaction_gas_limit: 30_000_000, + block_available_gas: 1_000_000, + }, + ); + + match classify_tx_outcome(Ok(Err(err)), TxHash::ZERO, &signed) { + TxOutcome::SkipExceedsGasLimit { + transaction_gas_limit, + block_available_gas, + } => { + assert_eq!(transaction_gas_limit, 30_000_000); + assert_eq!(block_available_gas, 1_000_000); + } + _ => panic!("expected SkipExceedsGasLimit"), + } + } + + // --- Tests for `arc_ethereum_payload` Arc-specific branches --- + // + // Covers the three Arc additions over reth's upstream builder: the + // loop-time-budget early seal, the in-loop Osaka `OversizedData` rejection, + // and the `is_better_payload` early abort. The harness pairs `EthEvmConfig` + // with an empty `MockEthProvider` and a recording `BestTransactions` iterator + // (`RecordingBestTxs`) to observe which branch ran via `mark_invalid` calls. + // + // The post-build `BlockTooLarge` branch is not covered: it needs a sealed + // block over `MAX_RLP_BLOCK_SIZE` (8 MiB) while every Osaka-gated in-loop + // estimate stayed under it, i.e. executing ~8 MiB of txs, with no production + // seam to shrink the constant. + mod arc_ethereum_payload_branches { + use super::*; + use alloy_primitives::{Address, Bytes, B256}; + use reth_basic_payload_builder::PayloadConfig; + use reth_chainspec::{ChainSpec, ChainSpecBuilder}; + use reth_evm_ethereum::EthEvmConfig; + use reth_payload_builder::PayloadId; + use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; + use reth_transaction_pool::test_utils::{MockTransaction, MockTransactionFactory}; + use reth_transaction_pool::ValidPoolTransaction; + use std::sync::Mutex; + + type TestPoolTx = MockTransaction; + type TestValidTx = ValidPoolTransaction; + + /// The kind of invalidation recorded by `RecordingBestTxs`. + /// + /// `InvalidPoolTransactionError` is neither `Clone` nor easily comparable, + /// so we project each `mark_invalid` call to the discriminant the tests need + /// to distinguish. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum MarkedKind { + OversizedData, + ExceedsGasLimit, + Other, + } + + impl MarkedKind { + fn from_err(err: &InvalidPoolTransactionError) -> Self { + match err { + InvalidPoolTransactionError::OversizedData { .. } => Self::OversizedData, + InvalidPoolTransactionError::ExceedsGasLimit(..) => Self::ExceedsGasLimit, + _ => Self::Other, + } + } + } + + /// A `BestTransactions` iterator over a fixed list of validated pool + /// transactions that records every `mark_invalid` call. This lets a test + /// feed exact transactions into `arc_ethereum_payload` and then assert which + /// ones the builder rejected and why. + /// + /// `BestTransactions: Send` requires the recorder state to be `Send`, hence + /// `Arc>` rather than `Rc>`. + struct RecordingBestTxs { + txs: std::vec::IntoIter>, + /// Records `(tx_hash, marked_kind)` for each `mark_invalid` call. + marked: Arc>>, + /// Counts how many times `next()` was polled. + polled: Arc>, + } + + impl Iterator for RecordingBestTxs { + type Item = Arc; + fn next(&mut self) -> Option { + let mut polled = self.polled.lock().expect("polled lock poisoned"); + *polled = polled.saturating_add(1); + drop(polled); + self.txs.next() + } + } + + impl BestTransactions for RecordingBestTxs { + fn mark_invalid(&mut self, tx: &Self::Item, kind: &InvalidPoolTransactionError) { + self.marked + .lock() + .expect("marked lock poisoned") + .push((*tx.hash(), MarkedKind::from_err(kind))); + } + fn no_updates(&mut self) {} + fn set_skip_blobs(&mut self, _skip_blobs: bool) {} + } + + /// Handles returned by the harness so the test can inspect builder behavior. + struct Recorders { + marked: Arc>>, + polled: Arc>, + } + + /// Builds an `EthEvmConfig` + `MockEthProvider` over a chain spec, plus a + /// `best_txs` closure yielding `txs`, and runs `arc_ethereum_payload`. + /// + /// `parent_gas_limit` flows into the next-block gas limit; set it high enough + /// that the per-tx gas-capacity check (L592) passes for txs that should reach + /// the size check. + fn run_payload( + chain_spec: Arc, + parent_gas_limit: u64, + loop_time_limit: Option, + best_payload: Option, + txs: Vec>, + ) -> ( + Result, PayloadBuilderError>, + Recorders, + ) { + let evm_config = EthEvmConfig::ethereum(chain_spec.clone()); + let provider = MockEthProvider::default().with_chain_spec((*chain_spec).clone()); + + // Parent header: timestamp 0 keeps Osaka/Prague active-at-timestamp(0) + // true, and the chosen gas limit drives next-block capacity. + let parent = alloy_consensus::Header { + gas_limit: parent_gas_limit, + number: 0, + timestamp: 0, + ..Default::default() + }; + let parent_header = reth_ethereum::primitives::SealedHeader::seal_slow(parent); + + // Fee recipient must be non-zero/funded enough for sealing to succeed on + // empty state; an empty `ExtendedAccount` is sufficient. + let fee_recipient = Address::repeat_byte(0xAA); + provider.add_account(fee_recipient, ExtendedAccount::new(0, U256::ZERO)); + + let attributes = reth_ethereum_engine_primitives::EthPayloadAttributes { + timestamp: 0, + prev_randao: B256::ZERO, + suggested_fee_recipient: fee_recipient, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, + }; + + let config = PayloadConfig { + parent_header: Arc::new(parent_header), + attributes, + payload_id: PayloadId::new([0u8; 8]), + }; + + let args = BuildArguments { + cached_reads: Default::default(), + execution_cache: None, + trie_handle: None, + config, + cancel: Default::default(), + best_payload, + }; + + let marked: Arc>> = Arc::new(Mutex::new(Vec::new())); + let polled = Arc::new(Mutex::new(0usize)); + let marked_for_closure = marked.clone(); + let polled_for_closure = polled.clone(); + + let best_txs = move |_attrs: BestTransactionsAttributes| -> BestTransactionsIter< + reth_transaction_pool::test_utils::TestPool, + > { + Box::new(RecordingBestTxs { + txs: txs.into_iter(), + marked: marked_for_closure, + polled: polled_for_closure, + }) + }; + + let pool = reth_transaction_pool::test_utils::testing_pool(); + let outcome = arc_ethereum_payload( + evm_config, + provider, + pool, + EthereumBuilderConfig::new(), + loop_time_limit, + args, + best_txs, + ); + + (outcome, Recorders { marked, polled }) + } + + fn prague_spec() -> Arc { + Arc::new(ChainSpecBuilder::mainnet().prague_activated().build()) + } + + fn osaka_spec() -> Arc { + Arc::new(ChainSpecBuilder::mainnet().osaka_activated().build()) + } + + /// Validates a `MockTransaction` into the `Arc>` + /// shape the iterator yields. + fn validate(tx: MockTransaction) -> Arc { + MockTransactionFactory::default().validated_arc(tx) + } + + /// AC #3: with `loop_time_limit = Some(Duration::ZERO)` and a pending tx + /// available, the loop breaks on the first iteration before including any + /// transaction. The sealed block has zero transactions, the iterator's first + /// `next()` was polled, and no tx was marked invalid. + #[test] + fn loop_time_limit_seals_empty_block_early() { + // Pre-Osaka spec keeps the path simple; branch 1 is not Osaka-gated. + let tx = MockTransaction::eip1559().with_gas_limit(21_000); + let txs = vec![validate(tx)]; + + let (outcome, rec) = + run_payload(prague_spec(), 30_000_000, Some(Duration::ZERO), None, txs); + + let outcome = outcome.expect("payload build should succeed"); + let payload = match outcome { + BuildOutcome::Better { payload, .. } => payload, + other => panic!("expected BuildOutcome::Better, got {other:?}"), + }; + + assert_eq!( + payload.block().body().transactions.len(), + 0, + "loop must break before including any tx" + ); + assert_eq!( + *rec.polled.lock().expect("polled lock poisoned"), + 1, + "iterator should be polled exactly once before the early break" + ); + assert!( + rec.marked.lock().expect("marked lock poisoned").is_empty(), + "no tx should be marked invalid on an early time-budget seal" + ); + } + + /// AC #1: with Osaka active and a transaction whose consensus RLP length + /// pushes the estimated block size above `MAX_RLP_BLOCK_SIZE`, the tx is + /// marked invalid with `OversizedData` and excluded from the sealed block. + #[test] + fn osaka_oversized_tx_marked_invalid_and_excluded() { + // ~8.5 MiB of calldata; well above MAX_RLP_BLOCK_SIZE (8 MiB). Small gas + // limit so the L592 capacity check passes (the tx is never executed). + let oversized_input = Bytes::from(vec![0u8; MAX_RLP_BLOCK_SIZE + 100_000]); + let oversized = MockTransaction::eip1559() + .with_gas_limit(21_000) + .with_input(oversized_input); + let oversized_tx = validate(oversized); + let oversized_hash = *oversized_tx.hash(); + + // Sanity: the consensus encoding really does exceed the limit, so the + // estimate-with-overhead check trips. + let consensus_len = oversized_tx.to_consensus().inner().length(); + assert!( + consensus_len > MAX_RLP_BLOCK_SIZE, + "test tx must encode larger than MAX_RLP_BLOCK_SIZE to hit the branch" + ); + + let (outcome, rec) = + run_payload(osaka_spec(), 30_000_000, None, None, vec![oversized_tx]); + + let outcome = outcome.expect("payload build should succeed"); + let payload = match outcome { + BuildOutcome::Better { payload, .. } => payload, + other => panic!("expected BuildOutcome::Better, got {other:?}"), + }; + + assert_eq!( + payload.block().body().transactions.len(), + 0, + "oversized tx must be excluded from the sealed block" + ); + + let marked = rec.marked.lock().expect("marked lock poisoned"); + assert_eq!(marked.len(), 1, "exactly one tx should be marked invalid"); + assert_eq!( + marked[0].0, oversized_hash, + "the oversized tx is the one marked" + ); + assert_eq!( + marked[0].1, + MarkedKind::OversizedData, + "tx must be marked invalid with OversizedData, got {:?}", + marked[0].1 + ); + } + + /// Regression guard: an undersized tx under Osaka is NOT marked + /// `OversizedData` by the in-loop check (it proceeds to execution). This pins + /// that the branch is size-gated, not unconditional. + #[test] + fn osaka_small_tx_not_marked_oversized() { + let small = MockTransaction::eip1559() + .with_gas_limit(21_000) + .with_input(Bytes::from(vec![0u8; 32])); + let small_tx = validate(small); + + let (outcome, rec) = run_payload(osaka_spec(), 30_000_000, None, None, vec![small_tx]); + + // The build itself must not error; we only assert the size branch did not + // fire. (Execution against empty MockEthProvider state may skip the tx as + // invalid for other reasons, which is fine — it must just not be + // OversizedData.) + assert!(outcome.is_ok(), "build should not error: {outcome:?}"); + assert!( + rec.marked + .lock() + .expect("marked lock poisoned") + .iter() + .all(|(_, kind)| *kind != MarkedKind::OversizedData), + "small tx must never be marked OversizedData" + ); + } + + /// AC #4: when `best_payload` carries higher fees than the block being built + /// (total_fees == 0 here, since no tx is executed), `is_better_payload` + /// returns false and the builder returns `BuildOutcome::Aborted` without + /// sealing. + #[test] + fn aborts_when_best_payload_has_higher_fees() { + // A stand-in "best" payload with non-zero fees. The sealed block content + // is irrelevant; only `fees()` is compared. + let better_block = { + let block = reth_ethereum::Block { + header: alloy_consensus::Header::default(), + body: Default::default(), + }; + let sealed = reth_ethereum::primitives::SealedBlock::from(block); + EthBuiltPayload::new(Arc::new(sealed), U256::from(1_000_000u64), None, None) + }; + + // No txs -> total_fees stays 0 -> not better than 1_000_000. + let (outcome, _rec) = + run_payload(prague_spec(), 30_000_000, None, Some(better_block), vec![]); + + let outcome = outcome.expect("payload build should succeed"); + match outcome { + BuildOutcome::Aborted { fees, .. } => { + assert_eq!( + fees, + U256::ZERO, + "aborted fees should be the (zero) total_fees" + ); + } + other => panic!("expected BuildOutcome::Aborted, got {other:?}"), + } + } + } } diff --git a/crates/execution-txpool/src/error.rs b/crates/execution-txpool/src/error.rs index 0158117b..99ba9aa2 100644 --- a/crates/execution-txpool/src/error.rs +++ b/crates/execution-txpool/src/error.rs @@ -27,6 +27,8 @@ pub enum ArcTransactionValidatorError { InvalidTxError, #[error("Address {0} is denylisted")] DenylistedAddressError(Address), + #[error("Too many EIP-7702 authorizations: {count}, limit {limit}")] + TooManyAuthorizations { count: usize, limit: usize }, } impl PoolTransactionError for ArcTransactionValidatorError { @@ -36,6 +38,8 @@ impl PoolTransactionError for ArcTransactionValidatorError { Self::InvalidTxError => true, // Node-local policy — peers can't know our denylist config Self::DenylistedAddressError(_) => false, + // Tunable Arc-side constant, not a cross-client protocol invariant + Self::TooManyAuthorizations { .. } => false, } } @@ -74,4 +78,16 @@ mod tests { "DenylistedAddressError must not be classified as bad transaction" ); } + + #[test] + fn too_many_authorizations_variant_is_not_bad_transaction() { + let err = ArcTransactionValidatorError::TooManyAuthorizations { + count: 200, + limit: 100, + }; + assert!( + !err.is_bad_transaction(), + "TooManyAuthorizations must not be classified as bad transaction — the limit is a tunable Arc constant, not a cross-client protocol invariant" + ); + } } diff --git a/crates/execution-txpool/src/pool.rs b/crates/execution-txpool/src/pool.rs index 15df6984..5480f63c 100644 --- a/crates/execution-txpool/src/pool.rs +++ b/crates/execution-txpool/src/pool.rs @@ -112,8 +112,10 @@ where #[cfg(test)] mod tests { use crate::ArcTransactionValidator; - use alloy_primitives::{B256, U256}; - use arc_execution_config::addresses_denylist::AddressesDenylistConfig; + use alloy_primitives::{Address, B256, U256}; + use arc_execution_config::addresses_denylist::{ + AddressesDenylistConfig, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + }; use reth_evm_ethereum::EthEvmConfig; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; use reth_transaction_pool::{ @@ -148,8 +150,13 @@ mod tests { } let eth_validator = builder.build(blob_store.clone()); - let arc_validator = - ArcTransactionValidator::new(eth_validator, None, AddressesDenylistConfig::Disabled); + // Denylist contract with no entries in the mock provider: nothing is denylisted. + let denylist_config = AddressesDenylistConfig::new( + Address::from([0x36u8; 20]), + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + ); + let arc_validator = ArcTransactionValidator::new(eth_validator, None, denylist_config); (arc_validator, blob_store) } diff --git a/crates/execution-txpool/src/validator.rs b/crates/execution-txpool/src/validator.rs index 5d9fb4d2..a68bd945 100644 --- a/crates/execution-txpool/src/validator.rs +++ b/crates/execution-txpool/src/validator.rs @@ -46,6 +46,11 @@ use tracing::{info, warn}; /// Default capacity for the invalid transaction list when no override is provided. pub const ARC_INVALID_TX_LIST_DEFAULT_CAP: u32 = 100_000; // 32 bytes * 100_000 = ~3.2 MB (+ LRU overhead) +/// Maximum EIP-7702 authorizations accepted in a single transaction, checked directly rather +/// than inferred from tx size or gas limits — recovery cost is paid before the sender's balance +/// or fee is checked, so this needs to bound the count itself, accurately. +pub const MAX_AUTHORIZATIONS_PER_TX: usize = 100; + /// Configuration for the invalid transaction list. #[derive(Debug, Clone)] pub struct InvalidTxListConfig { @@ -226,6 +231,36 @@ where } } + if let Err(err) = self.inner.validate_stateless(origin, &transaction) { + return TransactionValidationOutcome::Invalid(transaction, err); + } + + // Checked before blocklist/denylist below: a transaction over the limit is rejected + // here even if it would also have failed one of those checks. + let auth_count = transaction + .authorization_list() + .map_or(0, |list| list.len()); + if auth_count > MAX_AUTHORIZATIONS_PER_TX { + warn!( + origin = ?origin, + hash = %transaction.hash(), + sender = %transaction.sender(), + count = auth_count, + limit = MAX_AUTHORIZATIONS_PER_TX, + reason = "too_many_authorizations", + "transaction rejected" + ); + return TransactionValidationOutcome::Invalid( + transaction, + InvalidPoolTransactionError::other( + ArcTransactionValidatorError::TooManyAuthorizations { + count: auth_count, + limit: MAX_AUTHORIZATIONS_PER_TX, + }, + ), + ); + } + match self.inner.client().latest() { Ok(state_provider) => { match self.check_for_blocklisted_addresses(&transaction, &state_provider) { @@ -283,17 +318,16 @@ where } }; - // Store the provider for the inner validator for reuse + let outcome = self + .inner + .validate_stateful(origin, transaction, &state_provider); + + // Store the provider for reuse across subsequent validations. *state = Some(Box::new(state_provider)); + outcome } - Err(err) => { - return TransactionValidationOutcome::Error(*transaction.hash(), Box::new(err)); - } + Err(err) => TransactionValidationOutcome::Error(*transaction.hash(), Box::new(err)), } - - // If blocklist and addresses denylist validation pass, delegate to the inner validator - self.inner - .validate_one_with_state(origin, transaction, state) } /// If the transaction has a denylisted address, returns Ok(Some(address)); otherwise Ok(None). @@ -303,10 +337,6 @@ where transaction: &Tx, state_provider: &dyn StateProvider, ) -> ProviderResult> { - if !self.addresses_denylist_config.is_enabled() { - return Ok(None); - } - let auth_authorities: Vec
= transaction .authorization_list() .into_iter() @@ -415,6 +445,16 @@ mod tests { use serial_test::serial; use std::sync::atomic::{AtomicU64, Ordering}; + /// A denylist config whose contract has no denylisted entries in the mock provider, so every + /// lookup returns "not denylisted". Used by tests that are not about the denylist itself. + fn denylist_config_empty() -> AddressesDenylistConfig { + AddressesDenylistConfig::new( + Address::from([0x36u8; 20]), + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + ) + } + /// Helper function fn create_arc_validator_for_test( provider: MockEthProvider, @@ -428,7 +468,7 @@ mod tests { ArcTransactionValidator::new( eth_validator, Some(InvalidTxList::new(ARC_INVALID_TX_LIST_DEFAULT_CAP)), - AddressesDenylistConfig::Disabled, + denylist_config_empty(), ) } @@ -559,7 +599,7 @@ mod tests { .no_eip4844() .build(blob_store); let arc_validator = - ArcTransactionValidator::new(eth_validator, None, AddressesDenylistConfig::Disabled); // invalid tx list disabled + ArcTransactionValidator::new(eth_validator, None, denylist_config_empty()); // invalid tx list disabled let outcome = arc_validator .validate_one_with_state(TransactionOrigin::External, tx, &mut None) @@ -616,8 +656,7 @@ mod tests { } #[tokio::test] - async fn addresses_denylist_config_none_accepts_tx() { - // When addresses_denylist_config is None, no address denylist check; tx is accepted. + async fn denylist_contract_without_entries_accepts_tx() { let tx = MockTransaction::legacy() .with_gas_limit(21_000) .with_gas_price(1_000_000_000) @@ -633,13 +672,13 @@ mod tests { .no_eip4844() .build(blob_store); let arc_validator = - ArcTransactionValidator::new(eth_validator, None, AddressesDenylistConfig::Disabled); + ArcTransactionValidator::new(eth_validator, None, denylist_config_empty()); let outcome = arc_validator .validate_one_with_state(TransactionOrigin::External, tx, &mut None) .await; assert!( matches!(outcome, TransactionValidationOutcome::Valid { .. }), - "tx should be accepted when addresses_denylist_config is None" + "tx should be accepted when the denylist contract has no entries" ); } @@ -652,13 +691,11 @@ mod tests { .with_value(U256::from(200)); let sender = tx.sender(); let contract = Address::from([0x36u8; 20]); - let config = AddressesDenylistConfig::try_new( - true, - Some(contract), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), + let config = AddressesDenylistConfig::new( + contract, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, vec![sender], - ) - .unwrap(); + ); let provider = MockEthProvider::default(); provider.add_block(B256::ZERO, reth_ethereum_primitives::Block::default()); provider.add_account(sender, ExtendedAccount::new(0, U256::MAX)); @@ -687,13 +724,8 @@ mod tests { .with_gas_price(1_000_000_000) .with_value(U256::from(300)); let contract = Address::from([0x36u8; 20]); - let config = AddressesDenylistConfig::try_new( - true, - Some(contract), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), - Vec::new(), - ) - .unwrap(); + let config = + AddressesDenylistConfig::new(contract, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, Vec::new()); for (name, denylisted_address) in [ ("denylisted_sender", tx.sender()), @@ -779,9 +811,13 @@ mod tests { fn eip7702_tx_with_auths( auth_list: Vec, ) -> MockTransaction { - // 100_000 gas covers EIP-7702 intrinsic (21_000 + 12_500 per auth) + // 21_000 base + up to 25_000 per auth (PER_EMPTY_ACCOUNT_COST, since test authorities + // never exist in MockEthProvider state), plus margin. + let gas_limit = 25_000u64 + .saturating_mul(auth_list.len() as u64) + .saturating_add(71_000); let mut tx = MockTransaction::eip7702() - .with_gas_limit(100_000) + .with_gas_limit(gas_limit) .with_gas_price(1_000_000_000); tx.set_authorization_list(auth_list); tx @@ -809,13 +845,11 @@ mod tests { } fn denylist_config(exclusions: Vec
) -> AddressesDenylistConfig { - AddressesDenylistConfig::try_new( - true, - Some(DENYLIST_CONTRACT), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), + AddressesDenylistConfig::new( + DENYLIST_CONTRACT, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, exclusions, ) - .unwrap() } async fn validate_eip7702( @@ -833,6 +867,23 @@ mod tests { .await } + async fn validate_eip7702_with_max_tx_input_bytes( + tx: MockTransaction, + provider: MockEthProvider, + config: AddressesDenylistConfig, + max_tx_input_bytes: usize, + ) -> TransactionValidationOutcome { + let blob_store = InMemoryBlobStore::default(); + let eth_validator = EthTransactionValidatorBuilder::new(provider, EthEvmConfig::mainnet()) + .with_max_tx_input_bytes(max_tx_input_bytes) + .no_eip4844() + .build(blob_store); + let arc_validator = ArcTransactionValidator::new(eth_validator, None, config); + arc_validator + .validate_one_with_state(TransactionOrigin::External, tx, &mut None) + .await + } + fn assert_denylist_rejected( outcome: &TransactionValidationOutcome, expected_addr: Address, @@ -903,6 +954,76 @@ mod tests { assert_valid(&outcome); } + #[tokio::test] + async fn oversized_eip7702_rejected_before_denylist_authority_recovery() { + let max_tx_input_bytes = 128; + let (authority, signed_auth) = create_signed_authorization(1, Address::from([0xDD; 20]), 0); + let mut tx = eip7702_tx_with_auths(vec![signed_auth]); + tx.set_size(max_tx_input_bytes + 1); + let provider = provider_with_funded_accounts(&tx); + add_denylisted_address(&provider, authority); + + let outcome = validate_eip7702_with_max_tx_input_bytes( + tx, + provider, + denylist_config(Vec::new()), + max_tx_input_bytes, + ) + .await; + + let TransactionValidationOutcome::Invalid(_, err) = &outcome else { + panic!("expected oversized Invalid outcome, got {outcome:?}"); + }; + assert!( + matches!( + err, + InvalidPoolTransactionError::OversizedData { size, limit } + if *size == max_tx_input_bytes + 1 && *limit == max_tx_input_bytes + ), + "expected OversizedData before denylist authority recovery, got {err:?}" + ); + } + + #[tokio::test] + async fn too_many_authorizations_rejected_before_denylist_authority_recovery() { + let (authority, signed_auth) = create_signed_authorization(1, Address::from([0xDD; 20]), 0); + let auth_list: Vec<_> = + std::iter::repeat_n(signed_auth, MAX_AUTHORIZATIONS_PER_TX + 1).collect(); + let tx = eip7702_tx_with_auths(auth_list); + let provider = provider_with_funded_accounts(&tx); + add_denylisted_address(&provider, authority); + + let outcome = validate_eip7702(tx, provider, denylist_config(Vec::new())).await; + + let TransactionValidationOutcome::Invalid(_, err) = &outcome else { + panic!("expected Invalid outcome for too many authorizations, got {outcome:?}"); + }; + let inner: &ArcTransactionValidatorError = err + .downcast_other_ref::() + .unwrap(); + assert!( + matches!( + inner, + ArcTransactionValidatorError::TooManyAuthorizations { count, limit } + if *count == MAX_AUTHORIZATIONS_PER_TX + 1 && *limit == MAX_AUTHORIZATIONS_PER_TX + ), + "expected TooManyAuthorizations before denylist authority recovery, got {inner:?}" + ); + } + + #[tokio::test] + async fn authorization_count_at_limit_passes() { + let (_authority, signed_auth) = + create_signed_authorization(1, Address::from([0xEF; 20]), 0); + let auth_list: Vec<_> = + std::iter::repeat_n(signed_auth, MAX_AUTHORIZATIONS_PER_TX).collect(); + let tx = eip7702_tx_with_auths(auth_list); + let provider = provider_with_funded_accounts(&tx); + + let outcome = validate_eip7702(tx, provider, denylist_config(Vec::new())).await; + assert_valid(&outcome); + } + #[tokio::test] async fn eip7702_invalid_auth_signature_skipped() { // y_parity=2 forces SignatureError::InvalidParity, making recover_authority() return Err diff --git a/crates/execution-validation/src/consensus.rs b/crates/execution-validation/src/consensus.rs index 9bafc959..216f610e 100644 --- a/crates/execution-validation/src/consensus.rs +++ b/crates/execution-validation/src/consensus.rs @@ -29,7 +29,6 @@ use std::sync::Arc; use arc_execution_config::chainspec::{BaseFeeConfigProvider, BlockGasLimitProvider}; use arc_execution_config::gas_fee::decode_base_fee_from_bytes; -use arc_execution_config::hardforks::{is_arc_fork_active, ArcHardfork}; /// Arc Network custom consensus implementation #[derive(Debug, Clone)] @@ -57,20 +56,28 @@ where // Perform standard header validation (gas limits, etc.) validate_header_gas(header.header())?; - // Validate header timestamp (independent of parent) - arc_validate_header_timestamp(header.header())?; - - // ADR-0003: Validate gas limit is within chainspec bounds (Zero5+) + // NOTE(arc): The proposer clock-skew ("timestamp too far in the future") + // check is intentionally NOT run here. It reads the local wall clock, so it + // is non-deterministic; a hard Invalid at EL admission poisons reth's + // invalid-headers cache and is persisted, permanently stalling a + // clock-skewed node. The guard now runs at consensus vote time instead + // (arc-node-consensus `received_proposal_part` and `started_round` paths), + // where it downgrades only the prevote, never the persisted (execution-only) + // validity — so a certificate-backed block is still adopted via sync. + // Parent-relative timestamp monotonicity is still enforced in + // `validate_header_against_parent`. + + // ADR-0003: Validate gas limit is within chainspec bounds. arc_validate_gas_limit_bounds(header.header(), &self.chain_spec)?; - // ADR-0004: extra_data must be exactly 8 bytes (Zero5+). - arc_validate_extra_data_format(header.header(), &self.chain_spec)?; + // ADR-0004: extra_data must be exactly 8 bytes. + arc_validate_extra_data_format(header.header())?; - // ADR-0004: base_fee_per_gas must be present (EIP-1559) and within absolute bounds (Zero5+). + // ADR-0004: base_fee_per_gas must be present (EIP-1559) and within absolute bounds. arc_validate_header_base_fee(header.header(), &self.chain_spec)?; - // Reject blocks with a zero beneficiary (Zero6+). - arc_validate_beneficiary_nonzero(header.header(), &self.chain_spec)?; + // Reject blocks with a zero beneficiary. + arc_validate_beneficiary_nonzero(header.header())?; Ok(()) } @@ -89,7 +96,7 @@ where arc_validate_against_parent_timestamp(header.header(), parent.header())?; // 3. Validate base fee using Arc's algorithm - arc_validate_against_parent_base_fee(header.header(), parent.header(), &self.chain_spec)?; + arc_validate_against_parent_base_fee(header.header(), parent.header())?; // 4. Validate blob gas fields if applicable (EIP-4844) if let Some(blob_params) = self.chain_spec.blob_params_at_timestamp(header.timestamp()) { @@ -124,17 +131,12 @@ pub fn arc_validate_against_parent_timestamp( /// /// Decodes `nextBaseFee` from the parent's `extra_data` (written by the executor) and /// requires the child's `base_fee_per_gas` to match exactly. If `extra_data` is not -/// exactly 8 bytes (e.g. pre-Zero4 blocks), validation is silently skipped. +/// exactly 8 bytes, the block is malformed. /// /// Validation is always skipped when the parent is genesis (block 0). #[inline] -pub fn arc_validate_against_parent_base_fee( - header: &H, - parent: &H, - chain_spec: &ChainSpec, -) -> Result<(), ConsensusError> +pub fn arc_validate_against_parent_base_fee(header: &H, parent: &H) -> Result<(), ConsensusError> where - ChainSpec: EthChainSpec + EthereumHardforks + Hardforks, H: BlockHeader, { // Skip validation if parent is genesis block @@ -144,21 +146,10 @@ where // Decode the expected base fee from parent's extra_data let Some(expected_base_fee) = decode_base_fee_from_bytes(parent.extra_data()) else { - // Post-Zero5 this branch should be unreachable: `arc_validate_extra_data_format` - // enforces that extra_data is exactly 8 bytes. - if is_arc_fork_active( - chain_spec, - ArcHardfork::Zero5, - parent.number(), - parent.timestamp(), - ) { - tracing::error!( - parent_number = parent.number(), - extra_data_len = parent.extra_data().len(), - "Unexpectedly skipped base fee validation" - ); - } - return Ok(()); + return Err(ConsensusError::msg(format!( + "invalid parent extra_data length {}: must be 8 bytes", + parent.extra_data().len() + ))); }; // Get the actual base fee from the current header @@ -179,25 +170,13 @@ where /// Validates that the header's `extra_data` is exactly 8 bytes /// -/// Post-Zero5, the executor always writes `nextBaseFee` as an 8-byte big-endian u64. +/// The executor always writes `nextBaseFee` as an 8-byte big-endian u64. /// Any other length is malformed and should be rejected. #[inline] -fn arc_validate_extra_data_format( - header: &H, - chain_spec: &CS, -) -> Result<(), ConsensusError> { - if !is_arc_fork_active( - chain_spec, - ArcHardfork::Zero5, - header.number(), - header.timestamp(), - ) { - return Ok(()); - } - +fn arc_validate_extra_data_format(header: &H) -> Result<(), ConsensusError> { let len = header.extra_data().len(); if len != 8 { - return Err(ConsensusError::Other(format!( + return Err(ConsensusError::msg(format!( "invalid extra_data length {len}: must be 8 bytes" ))); } @@ -207,8 +186,8 @@ fn arc_validate_extra_data_format( /// Validates the header's `base_fee_per_gas`. /// -/// Performs the standard EIP-1559 presence check (via Reth's `validate_header_base_fee`) and, -/// post-Zero5, additionally enforces that the value lies within the chainspec's absolute bounds +/// Performs the standard EIP-1559 presence check (via Reth's `validate_header_base_fee`) and +/// enforces that the value lies within the chainspec's absolute bounds /// `[absolute_min_base_fee, absolute_max_base_fee]`. #[inline] fn arc_validate_header_base_fee< @@ -221,16 +200,6 @@ fn arc_validate_header_base_fee< // Standard EIP-1559 base_fee_per_gas presence check. validate_header_base_fee(header, chain_spec)?; - // Post-Zero5: enforce absolute bounds. - if !is_arc_fork_active( - chain_spec, - ArcHardfork::Zero5, - header.number(), - header.timestamp(), - ) { - return Ok(()); - } - let base_fee = match header.base_fee_per_gas() { Some(fee) => fee, None => return Ok(()), // already caught by validate_header_base_fee above @@ -250,26 +219,17 @@ fn arc_validate_header_base_fee< /// Validates that the header's gas limit is within the chainspec bounds. /// -/// This validation is only active when the Zero5 hardfork is enabled. +/// Arc validates this both statelessly here and statefully in the executor. #[inline] fn arc_validate_gas_limit_bounds( header: &H, chain_spec: &CS, ) -> Result<(), ConsensusError> { - if !is_arc_fork_active( - chain_spec, - ArcHardfork::Zero5, - header.number(), - header.timestamp(), - ) { - return Ok(()); - } - let gas_limit = header.gas_limit(); let config = chain_spec.block_gas_limit_config(header.number()); if gas_limit < config.min() || gas_limit > config.max() { - return Err(ConsensusError::Other(format!( + return Err(ConsensusError::msg(format!( "block gas limit {gas_limit} outside allowed bounds [{}, {}]", config.min(), config.max() @@ -281,66 +241,20 @@ fn arc_validate_gas_limit_bounds( - header: &H, - chain_spec: &CS, -) -> Result<(), ConsensusError> { - if !is_arc_fork_active( - chain_spec, - ArcHardfork::Zero6, - header.number(), - header.timestamp(), - ) { - return Ok(()); - } - +fn arc_validate_beneficiary_nonzero(header: &H) -> Result<(), ConsensusError> { if header.beneficiary().is_zero() { - return Err(ConsensusError::Other( - "block beneficiary must not be the zero address".into(), + return Err(ConsensusError::msg( + "block beneficiary must not be the zero address", )); } Ok(()) } -/// The maximum allowed clock skew for Arc proposers, in seconds. -const ARC_PROPOSER_CLOCK_SKEW_THRESHOLD: u64 = 30; // 30 seconds - -/// Validates that the header's timestamp is not too far in the future -/// compared to the local system time. -#[inline] -fn arc_validate_header_timestamp(header: &H) -> Result<(), ConsensusError> { - // Get the current local time in seconds since UNIX EPOCH - let local_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| ConsensusError::Other("System time is before UNIX EPOCH".to_string()))? - .as_secs(); - - arc_validate_header_timestamp_with_time(header, local_time) -} - -/// Validates that the header's timestamp is not too far in the future -/// compared to the local system time. -#[inline] -fn arc_validate_header_timestamp_with_time( - header: &H, - local_time: u64, -) -> Result<(), ConsensusError> { - // Validate that the header's timestamp is not too far in the future - if header.timestamp() > local_time.saturating_add(ARC_PROPOSER_CLOCK_SKEW_THRESHOLD) { - return Err(ConsensusError::TimestampIsInFuture { - timestamp: header.timestamp(), - present_timestamp: local_time, - }); - } - - Ok(()) -} - // Implement Consensus trait impl Consensus for ArcConsensus where @@ -384,8 +298,7 @@ where reth_ethereum::consensus::validate_block_post_execution( block, self.chain_spec.as_ref(), - &receipts.receipts, - &receipts.requests, + receipts, receipt_root_bloom, ) } @@ -396,10 +309,9 @@ mod tests { use super::*; use alloy_eips::eip7685::Requests; use alloy_primitives::{Bloom, B256}; - use arc_execution_config::chainspec::{ - localdev_with_hardforks, ArcChainSpec, BlockGasLimitProvider, LOCAL_DEV, - }; + use arc_execution_config::chainspec::{ArcChainSpec, BlockGasLimitProvider, LOCAL_DEV}; use arc_execution_config::gas_fee::encode_base_fee_to_bytes; + use arc_execution_config::hardforks::ArcHardfork; use reth_chainspec::{ChainSpecBuilder, ForkCondition}; use reth_ethereum::primitives::Header; use reth_ethereum_primitives::{EthPrimitives, Receipt}; @@ -456,6 +368,8 @@ mod tests { let parent_header = reth_ethereum::primitives::Header { timestamp: 1000, number: 1, + base_fee_per_gas: Some(0), + extra_data: encode_base_fee_to_bytes(0), ..Default::default() }; @@ -464,6 +378,7 @@ mod tests { let mut child_header = parent_header; child_header.number += 1; child_header.parent_hash = sealed_parent.hash(); + child_header.base_fee_per_gas = Some(0); // Set same timestamp child_header.timestamp = 1000; @@ -506,75 +421,8 @@ mod tests { )); } - #[test] - fn test_header_timestamp_just_within_skew_is_valid() { - let local_time = 1_730_887_500; // Some time in 2024 - let header = Header { - timestamp: local_time + ARC_PROPOSER_CLOCK_SKEW_THRESHOLD, - ..Header::default() - }; - - let result = arc_validate_header_timestamp_with_time(&header, local_time); - assert!( - result.is_ok(), - "Timestamp at the exact skew threshold should be valid" - ); - } - - #[test] - fn test_header_timestamp_in_past_is_valid() { - let local_time = 1_730_887_500; - let header = Header { - timestamp: local_time - 100, - ..Header::default() - }; - - let result = arc_validate_header_timestamp_with_time(&header, local_time); - assert!( - result.is_ok(), - "Timestamp in the past should be valid, as this check is only for future timestamps" - ); - } - - #[test] - fn test_header_timestamp_too_far_in_future_is_invalid() { - let local_time = 1_730_887_500; - let header = Header { - timestamp: local_time + ARC_PROPOSER_CLOCK_SKEW_THRESHOLD + 10, - ..Header::default() - }; - - let result = arc_validate_header_timestamp_with_time(&header, local_time); - assert!( - matches!( - result, - Err(ConsensusError::TimestampIsInFuture { - timestamp, - present_timestamp, - }) if timestamp == header.timestamp && present_timestamp == local_time - ), - "Timestamp beyond the skew threshold should be invalid" - ); - } - - #[test] - fn test_header_timestamp_at_current_time_is_valid() { - let local_time = 1_730_887_500; - let header = Header { - timestamp: local_time, - ..Header::default() - }; - - let result = arc_validate_header_timestamp_with_time(&header, local_time); - assert!( - result.is_ok(), - "Timestamp equal to local time should be valid" - ); - } - #[test] fn test_arc_base_fee_validation_with_matching_fees() { - let chain_spec = LOCAL_DEV.clone(); let expected_base_fee = 160_000_000_000u64; let parent_header = reth_ethereum::primitives::Header { @@ -592,8 +440,7 @@ mod tests { ..Default::default() }; - let result = - arc_validate_against_parent_base_fee(&child_header, &parent_header, &chain_spec); + let result = arc_validate_against_parent_base_fee(&child_header, &parent_header); assert!( result.is_ok(), "Base fee validation should succeed when fees match: {result:?}" @@ -602,7 +449,6 @@ mod tests { #[test] fn test_arc_base_fee_validation_with_mismatched_fees() { - let chain_spec = LOCAL_DEV.clone(); let expected_base_fee = 160_000_000_000u64; let parent_header = reth_ethereum::primitives::Header { @@ -620,8 +466,7 @@ mod tests { ..Default::default() }; - let result = - arc_validate_against_parent_base_fee(&child_header, &parent_header, &chain_spec); + let result = arc_validate_against_parent_base_fee(&child_header, &parent_header); assert!( matches!(result, Err(ConsensusError::BaseFeeDiff(_))), "Base fee validation should fail when fees mismatch: {result:?}" @@ -629,11 +474,9 @@ mod tests { } #[test] - fn test_arc_base_fee_validation_skips_malformed_extra_data() { + fn test_arc_base_fee_validation_rejects_malformed_extra_data() { use alloy_primitives::Bytes; - let chain_spec = LOCAL_DEV.clone(); - let parent_header = reth_ethereum::primitives::Header { number: 10, timestamp: 1000, @@ -649,18 +492,15 @@ mod tests { ..Default::default() }; - let result = - arc_validate_against_parent_base_fee(&child_header, &parent_header, &chain_spec); + let result = arc_validate_against_parent_base_fee(&child_header, &parent_header); assert!( - result.is_ok(), - "Base fee validation should skip when extra_data is malformed: {result:?}" + matches!(result, Err(ConsensusError::Other(_))), + "Base fee validation should reject malformed parent extra_data: {result:?}" ); } #[test] fn test_arc_base_fee_validation_missing_base_fee() { - let chain_spec = LOCAL_DEV.clone(); - let parent_header = reth_ethereum::primitives::Header { number: 10, timestamp: 1000, @@ -676,8 +516,7 @@ mod tests { ..Default::default() }; - let result = - arc_validate_against_parent_base_fee(&child_header, &parent_header, &chain_spec); + let result = arc_validate_against_parent_base_fee(&child_header, &parent_header); assert!( matches!(result, Err(ConsensusError::BaseFeeMissing)), "Base fee validation should fail when base_fee_per_gas is None: {result:?}" @@ -686,8 +525,6 @@ mod tests { #[test] fn test_arc_base_fee_validation_skips_genesis_parent() { - let chain_spec = LOCAL_DEV.clone(); - // Parent is genesis (block 0) let parent_header = reth_ethereum::primitives::Header { number: 0, @@ -706,8 +543,7 @@ mod tests { }; // Should pass because parent is genesis block - let result = - arc_validate_against_parent_base_fee(&child_header, &parent_header, &chain_spec); + let result = arc_validate_against_parent_base_fee(&child_header, &parent_header); assert!( result.is_ok(), "Validation should skip when parent is genesis block: {result:?}" @@ -954,30 +790,18 @@ mod tests { Err(ConsensusError::BaseFeeDiff(_)) )); } - - // Pre-Zero5: bounds check is skipped entirely. - let pre_zero5 = localdev_with_hardforks(&[(ArcHardfork::Zero4, ForkCondition::Block(0))]); - let header = Header { - number: 1, - base_fee_per_gas: Some(config.absolute_min_base_fee - 1), - ..Default::default() - }; - assert!(arc_validate_header_base_fee(&header, pre_zero5.as_ref()).is_ok()); } #[test] fn test_arc_validate_extra_data_format() { use alloy_primitives::Bytes; - // Zero5 - let spec = LOCAL_DEV.clone(); - let valid = Header { number: 1, extra_data: Bytes::from([0u8; 8].as_slice()), ..Default::default() }; - assert!(arc_validate_extra_data_format(&valid, spec.as_ref()).is_ok()); + assert!(arc_validate_extra_data_format(&valid).is_ok()); let too_short = Header { number: 1, @@ -985,7 +809,7 @@ mod tests { ..Default::default() }; assert!(matches!( - arc_validate_extra_data_format(&too_short, spec.as_ref()), + arc_validate_extra_data_format(&too_short), Err(ConsensusError::Other(_)) )); @@ -995,45 +819,38 @@ mod tests { ..Default::default() }; assert!(matches!( - arc_validate_extra_data_format(&too_long, spec.as_ref()), + arc_validate_extra_data_format(&too_long), Err(ConsensusError::Other(_)) )); + } + + #[test] + fn test_arc_validate_extra_data_format_is_unconditional() { + use alloy_primitives::Bytes; - // Pre-Zero5: length check is skipped entirely. - let pre_zero5 = localdev_with_hardforks(&[(ArcHardfork::Zero4, ForkCondition::Block(0))]); let header = Header { - number: 1, + number: 99, extra_data: Bytes::from([0u8; 7].as_slice()), ..Default::default() }; - assert!(arc_validate_extra_data_format(&header, pre_zero5.as_ref()).is_ok()); + + assert!(matches!( + arc_validate_extra_data_format(&header), + Err(ConsensusError::Other(_)) + )); } #[test] - fn test_gas_limit_validation_skipped_before_zero5() { - // Create a chain spec where Zero5 activates at block 100 + fn test_gas_limit_validation_enforced_before_zero5_activation() { + // Even if chain metadata schedules Zero5 later, Arc mainnet starts from the Zero6 baseline. let mut inner = ChainSpecBuilder::mainnet().build(); inner .hardforks .insert(ArcHardfork::Zero5, ForkCondition::Block(100)); let spec = Arc::new(ArcChainSpec::new(inner)); - // Before Zero5 — any gas limit should pass let header = Header { number: 99, - gas_limit: 0, // would be invalid after Zero5 - timestamp: 0, - ..Default::default() - }; - let result = arc_validate_gas_limit_bounds(&header, spec.as_ref()); - assert!( - result.is_ok(), - "Before Zero5, gas limit validation should be skipped: {result:?}" - ); - - // At Zero5 — now enforced - let header = Header { - number: 100, gas_limit: 0, timestamp: 0, ..Default::default() @@ -1041,7 +858,7 @@ mod tests { let result = arc_validate_gas_limit_bounds(&header, spec.as_ref()); assert!( matches!(result, Err(ConsensusError::Other(_))), - "At Zero5, gas limit 0 should be invalid: {result:?}" + "gas limit 0 should be invalid regardless of Zero5 activation: {result:?}" ); } @@ -1049,14 +866,12 @@ mod tests { fn test_beneficiary_nonzero_rejected_at_zero6() { use alloy_primitives::{address, Address}; - let spec = LOCAL_DEV.clone(); - let zero_beneficiary = Header { number: 1, beneficiary: Address::ZERO, ..Default::default() }; - let result = arc_validate_beneficiary_nonzero(&zero_beneficiary, spec.as_ref()); + let result = arc_validate_beneficiary_nonzero(&zero_beneficiary); assert!( matches!(result, Err(ConsensusError::Other(_))), "Zero beneficiary should be rejected post-Zero6: {result:?}" @@ -1067,7 +882,7 @@ mod tests { beneficiary: address!("0x65E0a200006D4FF91bD59F9694220dafc49dbBC1"), ..Default::default() }; - let result = arc_validate_beneficiary_nonzero(&nonzero_beneficiary, spec.as_ref()); + let result = arc_validate_beneficiary_nonzero(&nonzero_beneficiary); assert!( result.is_ok(), "Non-zero beneficiary should pass: {result:?}" @@ -1075,24 +890,79 @@ mod tests { } #[test] - fn test_beneficiary_nonzero_skipped_before_zero6() { + fn test_beneficiary_nonzero_is_unconditional() { use alloy_primitives::Address; - let mut inner = ChainSpecBuilder::mainnet().build(); - inner - .hardforks - .insert(ArcHardfork::Zero6, ForkCondition::Block(100)); - let spec = Arc::new(ArcChainSpec::new(inner)); - let header = Header { number: 99, beneficiary: Address::ZERO, ..Default::default() }; - let result = arc_validate_beneficiary_nonzero(&header, spec.as_ref()); + let result = arc_validate_beneficiary_nonzero(&header); + assert!( + matches!(result, Err(ConsensusError::Other(_))), + "zero beneficiary should be rejected regardless of Zero6 activation: {result:?}" + ); + } + + // Drives the public `HeaderValidator::validate_header` path so that + // accidentally removing the beneficiary check from `validate_header()` + // would be caught at the trait boundary, not just at the helper. + #[test] + fn validate_header_rejects_zero_beneficiary() { + use alloy_primitives::{Address, Bytes}; + + let spec = LOCAL_DEV.clone(); + let gas_limit = spec.block_gas_limit_config(1).default(); + let base_fee = spec.base_fee_config(1).absolute_min_base_fee; + let consensus = ArcConsensus::new(spec); + + let header = Header { + number: 1, + beneficiary: Address::ZERO, + gas_limit, + base_fee_per_gas: Some(base_fee), + extra_data: Bytes::from([0u8; 8].as_slice()), + ..Default::default() + }; + let sealed = SealedHeader::new(header.clone(), header.hash_slow()); + let result = + as HeaderValidator
>::validate_header(&consensus, &sealed); + assert!( + matches!(result, Err(ConsensusError::Other(_))), + "validate_header should reject zero beneficiary: {result:?}" + ); + } + + // The proposer clock-skew guard moved to the consensus layer, so EL header + // admission must no longer reject a far-future timestamp. Parent-relative + // monotonicity stays enforced (see `test_past_timestamp_is_invalid`). + #[test] + fn validate_header_accepts_far_future_timestamp() { + use alloy_primitives::{address, Bytes}; + + let spec = LOCAL_DEV.clone(); + let gas_limit = spec.block_gas_limit_config(1).default(); + let base_fee = spec.base_fee_config(1).absolute_min_base_fee; + let consensus = ArcConsensus::new(spec); + + let header = Header { + number: 1, + beneficiary: address!("0x65E0a200006D4FF91bD59F9694220dafc49dbBC1"), + gas_limit, + base_fee_per_gas: Some(base_fee), + extra_data: Bytes::from([0u8; 8].as_slice()), + // Far beyond any plausible clock-skew tolerance. + timestamp: u64::MAX, + ..Default::default() + }; + let sealed = SealedHeader::new(header.clone(), header.hash_slow()); + let result = + as HeaderValidator
>::validate_header(&consensus, &sealed); assert!( result.is_ok(), - "Before Zero6, zero beneficiary should be allowed: {result:?}" + "validate_header must accept a far-future timestamp now that the skew \ + guard runs at consensus vote time: {result:?}" ); } } diff --git a/crates/execution-validation/src/denylist.rs b/crates/execution-validation/src/denylist.rs index 42d16f5d..7485e4bf 100644 --- a/crates/execution-validation/src/denylist.rs +++ b/crates/execution-validation/src/denylist.rs @@ -64,8 +64,7 @@ impl DenylistStorageReader for T { /// /// Call from mempool validation with a state provider for the relevant block. /// -/// - Returns `Ok(false)` if config has denylist disabled, or `address` is in -/// `addresses_exclusions`. +/// - Returns `Ok(false)` if `address` is in `addresses_exclusions`. /// - Otherwise performs one SLOAD at `(contract_address, slot)` and returns /// `Ok(true)` iff the value is non-zero (denylisted). /// - On provider/SLOAD error returns `Err(DenylistError)`; callers may treat as fail-open (do not block). @@ -76,23 +75,16 @@ pub fn is_denylisted( config: &AddressesDenylistConfig, address: Address, ) -> Result { - let AddressesDenylistConfig::Enabled { - contract_address, - storage_slot, - .. - } = config - else { - return Ok(false); - }; - if config.is_address_excluded(&address) { trace!(%address, "address is explicitly excluded from denylist"); return Ok(false); } - let storage_key = - alloy_primitives::StorageKey::from(compute_denylist_storage_slot(address, *storage_slot)); - let value = provider.read_storage(*contract_address, storage_key)?; + let storage_key = alloy_primitives::StorageKey::from(compute_denylist_storage_slot( + address, + config.storage_slot(), + )); + let value = provider.read_storage(config.contract_address(), storage_key)?; // Non-zero means denylisted (bool true in Solidity). // None (uninitialized storage) equals zero (not denylisted per Solidity semantics). @@ -110,29 +102,14 @@ mod tests { B256::new(one) } - #[test] - fn is_denylisted_returns_false_when_disabled() { - let config = AddressesDenylistConfig::try_new( - false, - Some(Address::ZERO), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), - Vec::new(), - ) - .unwrap(); - let mock = MockDenylistStorageReader::new(); - assert!(!is_denylisted(&mock, &config, Address::from([1u8; 20])).unwrap()); - } - #[test] fn is_denylisted_returns_false_when_address_excluded() { let addr = Address::from([1u8; 20]); - let config = AddressesDenylistConfig::try_new( - true, - Some(Address::from([0x36u8; 20])), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), + let config = AddressesDenylistConfig::new( + Address::from([0x36u8; 20]), + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, vec![addr], - ) - .unwrap(); + ); let mock = MockDenylistStorageReader::new(); assert!(!is_denylisted(&mock, &config, addr).unwrap()); } @@ -141,13 +118,8 @@ mod tests { fn is_denylisted_returns_true_when_storage_non_zero() { let addr = Address::from([1u8; 20]); let contract = Address::from([0x36u8; 20]); - let config = AddressesDenylistConfig::try_new( - true, - Some(contract), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), - Vec::new(), - ) - .unwrap(); + let config = + AddressesDenylistConfig::new(contract, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, Vec::new()); let mut mock = MockDenylistStorageReader::new(); mock.expect_read_storage() .returning(|_addr, _slot| Ok(Some(denylisted_slot_value()))); @@ -158,13 +130,8 @@ mod tests { fn is_denylisted_returns_false_when_storage_zero() { let addr = Address::from([1u8; 20]); let contract = Address::from([0x36u8; 20]); - let config = AddressesDenylistConfig::try_new( - true, - Some(contract), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), - Vec::new(), - ) - .unwrap(); + let config = + AddressesDenylistConfig::new(contract, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, Vec::new()); let mut mock = MockDenylistStorageReader::new(); mock.expect_read_storage() .returning(|_addr, _slot| Ok(None)); @@ -173,13 +140,11 @@ mod tests { #[test] fn is_denylisted_returns_err_on_provider_error() { - let config = AddressesDenylistConfig::try_new( - true, - Some(Address::from([0x36u8; 20])), - Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), + let config = AddressesDenylistConfig::new( + Address::from([0x36u8; 20]), + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, Vec::new(), - ) - .unwrap(); + ); let mut mock = MockDenylistStorageReader::new(); mock.expect_read_storage().returning(|_addr, _slot| { Err(DenylistError::StorageReadFailed( diff --git a/crates/malachite-app/Cargo.toml b/crates/malachite-app/Cargo.toml index a827a4cd..fbb81a91 100644 --- a/crates/malachite-app/Cargo.toml +++ b/crates/malachite-app/Cargo.toml @@ -8,6 +8,12 @@ rust-version.workspace = true publish.workspace = true [features] +byzantine = [ + "arc-consensus-types/byzantine", + "arc-node-consensus-cli/byzantine", + "malachitebft-app-channel/byzantine", + "dep:malachitebft-engine-byzantine", +] pprof = ["dep:pprof_hyper_server"] # allocator @@ -50,6 +56,7 @@ itertools.workspace = true malachitebft-app-channel.workspace = true malachitebft-core-state-machine.workspace = true malachitebft-core-types.workspace = true +malachitebft-engine-byzantine = { workspace = true, optional = true } malachitebft-network.workspace = true malachitebft-peer.workspace = true malachitebft-sync.workspace = true @@ -69,6 +76,13 @@ thiserror.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true tokio-util.workspace = true +tower = { workspace = true, features = ["limit"] } +tower-http = { workspace = true, features = [ + "compression-gzip", + "compression-zstd", + "compression-br", + "compression-deflate", +] } tracing.workspace = true url.workspace = true @@ -80,6 +94,8 @@ arc-consensus-db = { workspace = true, features = ["mock"] } arc-consensus-types = { workspace = true, features = ["arbitrary"] } arc-eth-engine = { workspace = true, features = ["mocks"] } assert_cmd = "2.0" +brotli = "8" +flate2 = "1" malachitebft-network = { workspace = true } malachitebft-peer = { workspace = true, features = ["rand"] } mockall = { workspace = true } @@ -89,7 +105,7 @@ reqwest = { workspace = true, features = ["json"] } serial_test = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["test-util"] } -tower = { workspace = true } +zstd = "0.13" [lints] workspace = true diff --git a/crates/malachite-app/METRICS.md b/crates/malachite-app/METRICS.md index b73e0d46..89300ac1 100644 --- a/crates/malachite-app/METRICS.md +++ b/crates/malachite-app/METRICS.md @@ -71,9 +71,45 @@ This application exposes Prometheus metrics on the `/metrics` endpoint. The foll - `api`: The Engine API method name. - **Buckets:** Provides a histogram of API call times with exponential buckets from 0.001 to 2.0 seconds. +- **`arc_malachite_app_rpc_request_time`** (Histogram) + - **Description:** Time taken to serve RPC requests, in seconds. + - **Labels:** + - `endpoint`: The RPC endpoint path. + - **Buckets:** Provides a histogram of RPC request times with exponential buckets from 0.001 to 2.0 seconds. + + The `app_request_*` metric names below intentionally render with `app_app` in + Prometheus because the `arc_malachite_app` registry prefix is combined with + the registered `app_request_*` metric names. + +- **`arc_malachite_app_app_request_full_count`** (Counter) + - **Description:** Number of app requests rejected because the app request channel was full. + - **Labels:** + - `request`: The app request type. + +- **`arc_malachite_app_app_request_queue_time`** (Histogram) + - **Description:** Time app requests spend waiting in the app request channel, in seconds. + - **Labels:** + - `request`: The app request type. + - **Buckets:** Provides a histogram of app request queue times with exponential buckets from 0.001 to 2.0 seconds. + +- **`arc_malachite_app_app_request_process_time`** (Histogram) + - **Description:** Time taken to process app requests, in seconds. + - **Labels:** + - `request`: The app request type. + - **Buckets:** Provides a histogram of app request processing times with exponential buckets from 0.001 to 2.0 seconds. + - **`arc_malachite_app_height_restart_count`** (Counter) - **Description:** Number of times the consensus height has been restarted due to errors or recovery scenarios. +- **`arc_malachite_app_consensus_round_missed`** (Counter) + - **Description:** Number of consensus rounds that failed to decide before advancing to the next round. + - **Labels:** + - `proposer`: Address of the validator that was proposer for the missed round. + +- **`arc_malachite_app_clock_skew_nil_vote_count`** (Counter) + - **Description:** Number of vote-time nil-downgrade decisions taken because the proposer's header timestamp was more than 30 seconds ahead of local time, counted just before the value is handed to consensus. In rare cases the resulting prevote is not actually sent — the undecided-block store fails, or consensus drops the value at its per-(height, round) cap — so this is a near-upper bound on skew nil-prevotes rather than an exact tally. The block's persisted (execution-only) validity is untouched, so a value that later carries a commit certificate is still adopted via sync. It is zero in healthy operation; a rising count points at clock skew on this node or the proposer. + - **Labels:** + - `path`: Which path saw the proposal — `received_proposal_part` (live arrival) or `started_round` (buffered/early-arrival re-offered at round start). - **`arc_malachite_app_sync_fell_behind_count`** (Counter) - **Description:** Number of times the node fell behind and transitioned from InSync to CatchingUp. diff --git a/crates/malachite-app/README.md b/crates/malachite-app/README.md index 51989696..d5a06f54 100644 --- a/crates/malachite-app/README.md +++ b/crates/malachite-app/README.md @@ -24,6 +24,8 @@ It serves as a shim layer (proxy) between the execution client (EL), such as [re - [API Versioning](#api-versioning) - [Available Endpoints](#available-endpoints) - [Example API Usage](#example-api-usage) + - [Height Ranges](#height-ranges) + - [Response Compression](#response-compression) - [Deprecation Policy](#deprecation-policy) - [Metrics](#metrics) @@ -78,12 +80,17 @@ arc-node-consensus start \ --p2p.addr=/ip4/172.19.0.5/tcp/27000 \ --p2p.persistent-peers=/ip4/172.19.0.6/tcp/27000,/ip4/172.19.0.7/tcp/27000 \ --metrics=172.19.0.5:29000 \ - --rpc.addr=0.0.0.0:31000 \ + --rpc.addr=127.0.0.1:31000 \ --eth-socket=/tmp/reth.ipc \ --execution-socket=/tmp/auth.ipc \ --minimal ``` +> [!WARNING] +> **Deprecated.** The RPC transport is deprecated and will be removed in +> v0.9.0. Prefer the IPC example above. See +> [running-an-arc-node.md](../../docs/running-an-arc-node.md). + **Full example with RPC** (for remote deployments): ```bash @@ -95,7 +102,7 @@ arc-node-consensus start \ --p2p.addr=/ip4/172.19.0.5/tcp/27000 \ --p2p.persistent-peers=/ip4/172.19.0.6/tcp/27000,/ip4/172.19.0.7/tcp/27000 \ --metrics=0.0.0.0:29000 \ - --rpc.addr=0.0.0.0:31000 \ + --rpc.addr=172.19.0.5:31000 \ --eth-rpc-endpoint=http://localhost:8545 \ --execution-endpoint=http://localhost:8551 \ --execution-jwt=jwtsecret \ @@ -162,7 +169,8 @@ https://example.com,wss=ws.example.com:1212 - `--discovery.num-inbound-peers` - Number of inbound peers (default: 20) - `--value-sync` - Enable value sync (default: true) - `--metrics` - Enable metrics and set listen address (e.g., "0.0.0.0:29000") -- `--rpc.addr` - Enable RPC and set listen address (e.g., "0.0.0.0:31000") +- `--rpc.addr` - Enable RPC and set listen address (e.g., "127.0.0.1:31000") +- `--rpc.admin` - Enable the admin RPC routes (state-mutating persistent-peer add/remove). Disabled by default. These routes have no authentication, so they stay unreachable unless explicitly enabled, and **should only be exposed on an internal, trusted interface**. - `--full` - Arc full-node pruning preset; sets `--prune.certificates.distance 237600`; mutually exclusive with `--minimal` and the individual `--prune.certificates.*` flags - `--minimal` - Arc minimal-storage pruning preset; sets `--prune.certificates.distance 237600`; mutually exclusive with `--full` and the individual `--prune.certificates.*` flags - `--prune.certificates.distance` - Keep certificates for the last N heights (default: 0, disabled/archive node); mutually exclusive with `--prune.certificates.before` and `--full/--minimal` presets @@ -211,16 +219,23 @@ arc-node-consensus download \ --url ``` -If `--url` is omitted, the latest pruned snapshot for the selected `--chain` is fetched automatically from the snapshot API. +If `--url` is omitted, the newest storage v2 entry that carries both layers is +selected for `--chain`, regardless of retention. The consensus archive from +that entry is fetched without falling back to the v1 listing. The current +selection is an archive snapshot measured at 42.29 GB, up from 15.37 GB for the +previous v1 pruned selection. ```bash -# Devnet — latest snapshot (recommended) +# Testnet latest snapshot arc-node-consensus download \ --home=~/.arc/consensus \ - --chain arc-devnet + --chain arc-testnet ``` -> For a full node restore (EL + CL), use the `arc-snapshots` tool instead — it downloads both archives in one command. See [`crates/snapshots/README.md`](../snapshots/README.md). +> For a paired node restore, use the `arc-snapshots` tool. Automatic resolution +> restores a storage v2 execution manifest and its consensus archive from one +> listing entry. Explicit URLs can still select the native archive restore. See +> [`crates/snapshots/README.md`](../snapshots/README.md). ### Key @@ -274,7 +289,9 @@ The following environment variables can be used to modify behavior: ## REST API -The consensus layer exposes a REST API for monitoring and querying consensus state when `--rpc.addr` is set (e.g., `--rpc.addr=0.0.0.0:26658`). +The consensus layer exposes a REST API for monitoring and querying consensus state when `--rpc.addr` is set (e.g., `--rpc.addr=127.0.0.1:26658`). + +The read-only endpoints below are always available when the RPC server is enabled. The state-mutating admin routes (`POST`/`DELETE /persistent-peers`) are gated behind `--rpc.admin` and disabled by default: they are not served and not listed in the API index unless that flag is set. They have no authentication, so **only enable them on an internal, trusted interface**. ### API Versioning @@ -328,10 +345,18 @@ All endpoints support versioning: - `GET /` - API documentation and versioning info - `GET /status` - Application status - `GET /health` - Health check +- `GET /ready` - Readiness probe (200 in sync, 503 catching up) - `GET /version` - Version information (git, cargo) - `GET /consensus-state` - Current consensus state -- `GET /commit?height=N` - Commit certificate for specific height - `GET /network-state` - Network peer information +- `GET /commit?height=N[&count=C]` - Commit certificate(s) for a height or range +- `GET /misbehavior-evidence?height=N[&count=C]` - Misbehavior evidence for a height or range +- `GET /proposal-monitor?height=N[&count=C]` - Round-0 proposal monitoring data for a height or range +- `GET /invalid-payloads?height=N[&count=C]` - Invalid payloads for a height or range + +The four observability endpoints (`/commit`, `/misbehavior-evidence`, +`/proposal-monitor`, `/invalid-payloads`) accept an optional `count` for range +queries — see [Height Ranges](#height-ranges). #### Example API Usage @@ -356,6 +381,80 @@ curl http://localhost:26658/health curl http://localhost:26658/ ``` +### Height Ranges + +The four observability endpoints — `/commit`, `/misbehavior-evidence`, +`/proposal-monitor`, and `/invalid-payloads` — accept an optional `count` query +parameter to fetch a contiguous forward range of heights in one request: + +- `count` is the total number of heights **including** `height`. It defaults to + `1`. `count=1` (or omitting `count`) is identical to the single-height + behavior and returns a single JSON object. +- `count > 1` returns an ordered JSON **array** of the per-height object, for + heights `height, height+1, …, height+count-1`. An explicit `height` is + required; `count > 1` without `height` is a `400 Bad Request`. +- `count` is capped at **1000**. Requests above the cap are rejected with a + `400 Bad Request` (see below). The limit is fixed, not configurable. + +```bash +# A single height (unchanged): +curl "http://localhost:26658/commit?height=100" | jq + +# 50 contiguous heights (100..=149) as a JSON array: +curl "http://localhost:26658/commit?height=100&count=50" | jq +``` + +#### Range Errors + +When a range cannot be fully served, the endpoint returns `400 Bad Request` +with a structured body identifying the failing heights, uniform across all four +endpoints, e.g., for a request that exceeds the current head: + +```json +{ + "error": "partial range unavailable", + "requested": { "from": 100, "to": 149 }, + "failed_heights": [147, 148, 149], + "reason": "above_current_head" +} +``` + +- `requested` — the inclusive `from..=to` range that was asked for. +- `failed_heights` — the exact heights that could not be served, so a client + can retry only those. Omitted when empty. +- `reason` — one of: + - `above_current_head` — the height is past the latest decided height. + - `pruned` — the height is below the earliest retained height. + - `not_recorded` — within range but never recorded (proposal monitor only; a + permanent gap, not retryable). + - `internal` — a record is missing or could not be decoded. + - `over_limit` — `count` exceeds the 1000 cap. `failed_heights` is omitted + (the range is never evaluated); `requested` still carries the bounds. + +Plain argument errors (`count=0`, `count > 1` without `height`, or a range that +overflows `u64`) return a simpler `400 Bad Request` of the form `{"error": "..."}`. + +When the store holds no decided heights yet (an empty table — e.g. a freshly +started node), a range request returns `404 Not Found` with a per-endpoint body +(e.g., `{"error": "Certificate not found"}` on `/commit`, and the analogous message on +the other three) rather than the structured `400` above. + +### Response Compression + +Responses are compressed when the client opts in via the standard +`Accept-Encoding` header; the server negotiates a codec the client offered and +sets `Content-Encoding` accordingly. The server supports `gzip`, `zstd`, `br` +(brotli), and `deflate`. Clients that send no `Accept-Encoding` receive +uncompressed responses unchanged. + +```bash +# curl negotiates and transparently decompresses with --compressed: +curl --compressed "http://localhost:26658/commit?height=100&count=1000" | jq +``` + +Very small responses (under ~32 bytes, e.g. `/health`) are sent uncompressed +regardless. + ### Deprecation Policy When breaking changes are introduced: diff --git a/crates/malachite-app/src/app.rs b/crates/malachite-app/src/app.rs index a8193af7..47e0d958 100644 --- a/crates/malachite-app/src/app.rs +++ b/crates/malachite-app/src/app.rs @@ -16,19 +16,33 @@ use eyre::{eyre, Context as _}; use tokio::sync::mpsc::Receiver; +use tokio::sync::watch; +use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use malachitebft_app_channel::{AppMsg, Channels}; use malachitebft_core_types::utils::height::DisplayRange; -use malachitebft_core_types::Context; +use arc_consensus_types::proposer::{ProposerSelector, RoundRobin}; use arc_consensus_types::{ArcContext, StoredCommitCertificate}; use arc_eth_engine::engine::Engine; use crate::handlers::*; +use crate::metrics::AppMetrics; use crate::request::{AppRequest, CommitCertificateInfo}; -use crate::state::State; +use crate::state::{State, StatusSnapshot}; +use crate::stats::Stats; +use crate::store::{RangeFailureReason, RangeQueryResult, Store}; + +/// Returned by [`go`] when the consensus channel closes. +/// +/// During a graceful shutdown the Node actor is stopped first, which closes this +/// channel on purpose; outside of shutdown it means consensus stopped unexpectedly +/// and the node should restart. +#[derive(Debug, thiserror::Error)] +#[error("Consensus channel closed unexpectedly")] +struct ConsensusChannelClosed; pub async fn run( mut state: State, @@ -36,20 +50,57 @@ pub async fn run( engine: Engine, rx_app_req: Receiver, cancel_token: CancellationToken, + graceful_shutdown: CancellationToken, ) -> eyre::Result<()> { if let Some(halt_height) = state.env_config().halt_height { warn!("Consensus configured to halt at block height: {halt_height}"); } + let (status_tx, status_rx) = watch::channel(state.status_snapshot()); + + let ctx = AppRequestContext { + store: state.store().clone(), + engine: engine.clone(), + stats: state.stats().clone(), + metrics: state.metrics().clone(), + proposer_selector: state.ctx.proposer_selector, + status_rx, + }; + + let mut app_req_task: JoinHandle> = + tokio::spawn(process_app_requests(rx_app_req, ctx)); + let result = cancel_token - .run_until_cancelled_owned(go(&mut state, channels, &engine, rx_app_req)) + .run_until_cancelled_owned(go( + &mut state, + channels, + &engine, + status_tx, + &mut app_req_task, + )) .await; + // Always abort the app-request task, including on graceful cancellation + // where `go` is dropped at an await point and would otherwise detach it. + app_req_task.abort(); + let result = match result { + Some(Ok(never)) => match never {}, Some(Err(e)) => { - error!("🔴 Error in application: {e:#}"); - error!("🔴 Shutting down"); - Err(e) + // A closed consensus channel during a graceful shutdown means the Node + // actor was stopped first on purpose, so treat it as a clean exit. Any + // other error — or a closed channel outside of shutdown — is a genuine + // failure and must still surface so the node restarts. + if graceful_shutdown.is_cancelled() + && e.downcast_ref::().is_some() + { + info!("🟢🟢 Application is shutting down gracefully"); + Ok(()) + } else { + error!("🔴 Error in application: {e:#}"); + error!("🔴 Shutting down"); + Err(e) + } } None => { info!("🟢🟢 Application is shutting down gracefully"); @@ -68,10 +119,42 @@ pub async fn run( /// Used to indicate that the function never returns normally. enum Never {} +/// Context shared with the spawned app-request processing task. +/// +/// All fields are cheap to clone (`Arc`-based or `Copy`). +struct AppRequestContext { + store: Store, + engine: Engine, + stats: Stats, + metrics: AppMetrics, + proposer_selector: RoundRobin, + status_rx: watch::Receiver, +} + +/// Runs in a dedicated tokio task, processing app requests concurrently +/// with the consensus event loop. +async fn process_app_requests( + mut rx: Receiver, + ctx: AppRequestContext, +) -> eyre::Result { + loop { + match rx.recv().await { + Some(req) => { + if let Err(e) = handle_app_request(req, &ctx).await { + error!("🔴 Error handling application request: {e:#}"); + } + } + None => { + return Err(eyre!("Application request channel closed unexpectedly")); + } + } + } +} + /// The main event loop of the application. /// -/// It listens for messages from consensus and application requests, -/// and dispatches them to the appropriate handlers. +/// It listens for messages from consensus and monitors the app-request task. +/// App requests are processed in a separate task so they don't block consensus. /// /// # Errors /// Returns an error if handling a message fails or one of the channels is closed unexpectedly. @@ -81,7 +164,8 @@ async fn go( state: &mut State, mut channels: Channels, engine: &Engine, - mut rx_app_req: Receiver, + status_tx: watch::Sender, + app_req_task: &mut JoinHandle>, ) -> eyre::Result { loop { tokio::select! { @@ -92,25 +176,33 @@ async fn go( // Abort on error to shut down the application. handle_consensus(msg, state, &mut channels, engine).await .wrap_err("Error handling consensus message")?; + + // Skip the publish when nothing changed: most consensus messages + // (sync queries, restream requests, vote-extension hooks) leave the + // snapshot untouched, so there is no point updating the watch. + status_tx.send_if_modified(|current| { + let new = state.status_snapshot(); + if *current != new { + *current = new; + true + } else { + false + } + }); }, None => { - return Err(eyre!("Consensus channel closed unexpectedly")); + return Err(ConsensusChannelClosed.into()); } }, - req = rx_app_req.recv() => match req { - Some(req) => { - if let Err(e) = handle_app_request(req, state, engine).await { - error!("🔴 Error handling application request: {e:#}"); - - // We continue processing other requests even if one fails. - continue; - } - }, - None => { - return Err(eyre!("Application request channel closed unexpectedly")); + result = &mut *app_req_task => { + // The app-request task should run forever; if it exits, propagate the error. + match result { + Ok(Ok(never)) => match never {}, + Ok(Err(e)) => return Err(e.wrap_err("App request task failed")), + Err(e) => return Err(eyre!("App request task panicked: {e}")), } - } + }, } } } @@ -302,20 +394,35 @@ async fn handle_consensus( Ok(()) } -#[allow(clippy::unit_arg)] -async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> eyre::Result<()> { +async fn handle_app_request(req: AppRequest, ctx: &AppRequestContext) -> eyre::Result<()> { match req { - AppRequest::GetCertificate(height, reply) => { - let result = state - .store() - .get_certificate(height) - .await - .wrap_err_with(|| { - format!("GetCertificate: Failed to get certificate for height {height:?}") - })?; + AppRequest::GetCertificate { + height, + enqueued_at, + reply, + } => { + ctx.metrics.observe_app_request_queue_time( + "GetCertificate", + enqueued_at.elapsed().as_secs_f64(), + ); + let _guard = ctx + .metrics + .start_app_request_process_timer("GetCertificate"); + + let result = ctx.store.get_certificate(height).await.wrap_err_with(|| { + format!("GetCertificate: Failed to get certificate for height {height:?}") + })?; let info = match result { - Some(certificate) => get_certificate_info(&state.ctx, engine, certificate).await, + Some(certificate) => { + get_certificate_info( + ctx.proposer_selector, + &ctx.engine, + &ctx.metrics, + certificate, + ) + .await + } None => None, }; @@ -325,7 +432,7 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetMisbehaviorEvidence(height, reply) => { - let evidence = state.store().get_misbehavior_evidence(height).await.wrap_err_with(|| { + let evidence = ctx.store.get_misbehavior_evidence(height).await.wrap_err_with(|| { format!( "GetMisbehaviorEvidence: Failed to get misbehavior evidence for height {height:?}", ) @@ -336,8 +443,7 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetProposalMonitorData(height, reply) => { - let data = state - .store() + let data = ctx.store .get_proposal_monitor_data(height) .await .wrap_err_with(|| { @@ -352,7 +458,7 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetInvalidPayloads(height, reply) => { - let payloads = state.store().get_invalid_payloads(height).await.wrap_err_with(|| { + let payloads = ctx.store.get_invalid_payloads(height).await.wrap_err_with(|| { format!( "Failed to get invalid payloads for height {:?} in response to a GetInvalidPayloads request", height, ) @@ -362,9 +468,82 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } } + AppRequest::GetCertificateRange(range, reply) => { + let result = ctx + .store + .get_certificate_range(range.from, range.count) + .await + .wrap_err_with(|| { + format!("GetCertificateRange: Failed to get certificates for {range:?}") + })?; + + let info = match result { + None => None, + Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + }) => Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + }), + Some(RangeQueryResult::Complete(certs)) => Some( + get_certificates_info(ctx.proposer_selector, &ctx.engine, &ctx.metrics, certs) + .await, + ), + }; + + if let Err(e) = reply.send(info) { + error!("GetCertificateRange: Failed to reply: {e:?}"); + } + } + + AppRequest::GetMisbehaviorEvidenceRange(range, reply) => { + let evidence = ctx + .store + .get_misbehavior_evidence_range(range.from, range.count) + .await + .wrap_err_with(|| { + format!( + "GetMisbehaviorEvidenceRange: Failed to get misbehavior evidence for {range:?}" + ) + })?; + if let Err(e) = reply.send(evidence) { + error!("GetMisbehaviorEvidenceRange: Failed to reply: {e:?}"); + } + } + + AppRequest::GetProposalMonitorDataRange(range, reply) => { + let data = ctx + .store + .get_proposal_monitor_data_range(range.from, range.count) + .await + .wrap_err_with(|| { + format!( + "GetProposalMonitorDataRange: Failed to get proposal monitor data for {range:?}" + ) + })?; + if let Err(e) = reply.send(data) { + error!("GetProposalMonitorDataRange: Failed to reply: {e:?}"); + } + } + + AppRequest::GetInvalidPayloadsRange(range, reply) => { + let payloads = ctx + .store + .get_invalid_payloads_range(range.from, range.count) + .await + .wrap_err_with(|| { + format!("GetInvalidPayloadsRange: Failed to get invalid payloads for {range:?}") + })?; + if let Err(e) = reply.send(payloads) { + error!("GetInvalidPayloadsRange: Failed to reply: {e:?}"); + } + } + AppRequest::GetStatus(reply) => { - let status = state - .get_status() + let snapshot = ctx.status_rx.borrow().clone(); + let status = snapshot + .get_status(&ctx.store, &ctx.stats) .await .wrap_err("GetStatus: Failed to get the current status")?; @@ -374,13 +553,14 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetHealth(reply) => { - if let Err(e) = reply.send(state.get_health()) { + if let Err(e) = reply.send(()) { error!("GetHealth: Failed to reply: {e:?}"); } } AppRequest::GetSyncState(reply) => { - if let Err(e) = reply.send(state.sync_state) { + let sync_state = ctx.status_rx.borrow().sync_state; + if let Err(e) = reply.send(sync_state) { error!("GetSyncState: Failed to reply: {e:?}"); } } @@ -390,27 +570,36 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } async fn get_certificate_info( - ctx: &ArcContext, + proposer_selector: impl ProposerSelector, engine: &Engine, + metrics: &AppMetrics, stored: StoredCommitCertificate, ) -> Option { - // The validator set that signed the certificate is the one *before* executing that block, - // since the block itself could contain validator set changes. - let prev_height = stored.certificate.height.as_u64().saturating_sub(1); - let validator_set = engine - .eth - .get_active_validator_set(prev_height) - .await - .ok()?; + if let Some(proposer) = stored.proposer { + return Some(CommitCertificateInfo { + certificate: stored.certificate, + certificate_type: stored.certificate_type, + proposer, + }); + } + + let validator_set = { + let _guard = + metrics.start_engine_api_timer("get_certificate_info.get_signing_validator_set"); + engine + .eth + .get_signing_validator_set(stored.certificate.height.as_u64()) + .await + .ok()? + }; - let proposer = stored.proposer.unwrap_or_else(|| { - ctx.select_proposer( + let proposer = proposer_selector + .select_proposer( &validator_set, stored.certificate.height, stored.certificate.round, ) - .address - }); + .address; Some(CommitCertificateInfo { certificate: stored.certificate, @@ -419,17 +608,57 @@ async fn get_certificate_info( }) } +/// Resolve proposers for a complete range of stored certificates. +/// +/// Reuses [`get_certificate_info`], so the validator-set lookup only runs +/// for legacy rows without a stored proposer. Heights whose lookup fails +/// become an `Internal` range failure. +async fn get_certificates_info( + proposer_selector: impl ProposerSelector + Copy, + engine: &Engine, + metrics: &AppMetrics, + certs: Vec, +) -> RangeQueryResult { + let mut infos = Vec::with_capacity(certs.len()); + let mut failed_heights = Vec::new(); + + // TODO(perf): if ranging over legacy (proposer == None) rows becomes a + // bottleneck, batch these eth_call valset lookups like get_execution_payloads. + // Only reachable for pre-migration rows on an archive EL; bounded by the + // MAX_RANGE_COUNT constant. + for stored in certs { + let height = stored.certificate.height; + match get_certificate_info(proposer_selector, engine, metrics, stored).await { + Some(info) => infos.push(info), + None => failed_heights.push(height), + } + } + + if failed_heights.is_empty() { + RangeQueryResult::Complete(infos) + } else { + RangeQueryResult::Unavailable { + reason: RangeFailureReason::Internal, + failed_heights, + } + } +} + #[cfg(test)] mod tests { use super::*; + use arc_consensus_types::signing::PrivateKey; use arc_consensus_types::{ - Address, BlockHash, CommitCertificate, CommitCertificateType, Height, Round, ValueId, + Address, BlockHash, CommitCertificate, CommitCertificateType, Height, Round, Validator, + ValidatorSet, ValueId, }; use arc_eth_engine::engine::{MockEngineAPI, MockEthereumAPI}; use mockall::predicate::eq; + use rand::rngs::StdRng; + use rand::SeedableRng; - fn stored_cert(height: u64) -> StoredCommitCertificate { + fn stored_cert(height: u64, proposer: Option
) -> StoredCommitCertificate { StoredCommitCertificate { certificate: CommitCertificate::new( Height::new(height), @@ -438,53 +667,175 @@ mod tests { vec![], ), certificate_type: CommitCertificateType::Minimal, - // Set so get_certificate_info skips select_proposer (keeps the test focused - // on the validator set lookup). - proposer: Some(Address::new([0x42; 20])), + proposer, } } - /// get_certificate_info must fetch the validator set at `certificate.height - 1` — the - /// set that signed the certificate, i.e. the state *before* executing the certified block. + fn validator_set() -> ValidatorSet { + let mut rng = StdRng::seed_from_u64(0x42); + let signing_key = PrivateKey::generate(&mut rng); + ValidatorSet::new(vec![Validator::new(signing_key.public_key(), 1)]) + } + #[tokio::test] - async fn get_certificate_info_queries_validator_set_at_prev_height() { + async fn get_certificate_info_uses_stored_proposer_without_validator_set_lookup() { + let stored_proposer = Address::new([0x42; 20]); + let engine = Engine::new( + Box::new(MockEngineAPI::new()), + Box::new(MockEthereumAPI::new()), + ); + let metrics = AppMetrics::default(); + let ctx = ArcContext::default(); + + let info = get_certificate_info( + ctx.proposer_selector, + &engine, + &metrics, + stored_cert(42, Some(stored_proposer)), + ) + .await + .expect("should return Some"); + + assert_eq!(info.proposer, stored_proposer); + assert_eq!(info.certificate.height, Height::new(42)); + } + + /// get_certificate_info passes the certificate's consensus height directly; + /// the EthereumAPI impl is responsible for querying at `consensus_height - 1`. + #[tokio::test] + async fn get_certificate_info_queries_signing_validator_set_when_proposer_is_missing() { let cert_height = 42u64; + let fallback_validator_set = validator_set(); + let expected_proposer = fallback_validator_set + .get_by_index(0) + .expect("test validator set is non-empty") + .address; let mut mock_eth = MockEthereumAPI::new(); mock_eth - .expect_get_active_validator_set() - .with(eq(cert_height - 1)) + .expect_get_signing_validator_set() + .with(eq(cert_height)) .once() - .returning(|_| Ok(Default::default())); + .returning(move |_| Ok(fallback_validator_set.clone())); let engine = Engine::new(Box::new(MockEngineAPI::new()), Box::new(mock_eth)); + let metrics = AppMetrics::default(); let ctx = ArcContext::default(); - let info = get_certificate_info(&ctx, &engine, stored_cert(cert_height)) - .await - .expect("should return Some"); + let info = get_certificate_info( + ctx.proposer_selector, + &engine, + &metrics, + stored_cert(cert_height, None), + ) + .await + .expect("should return Some"); assert_eq!(info.certificate.height, Height::new(cert_height)); + assert_eq!(info.proposer, expected_proposer); + } + + #[tokio::test] + async fn get_certificates_info_skips_engine_when_proposers_are_stored() { + let proposer = Address::new([0x42; 20]); + // No mock expectations: any engine call would panic. + let engine = Engine::new( + Box::new(MockEngineAPI::new()), + Box::new(MockEthereumAPI::new()), + ); + let metrics = AppMetrics::default(); + let ctx = ArcContext::default(); + + let result = get_certificates_info( + ctx.proposer_selector, + &engine, + &metrics, + vec![ + stored_cert(1, Some(proposer)), + stored_cert(2, Some(proposer)), + stored_cert(3, Some(proposer)), + ], + ) + .await; + + let RangeQueryResult::Complete(infos) = result else { + panic!("expected complete range"); + }; + let heights: Vec = infos + .iter() + .map(|i| i.certificate.height.as_u64()) + .collect(); + assert_eq!(heights, vec![1, 2, 3]); + assert!(infos.iter().all(|i| i.proposer == proposer)); + } + + #[tokio::test] + async fn get_certificates_info_resolves_legacy_rows_via_validator_set() { + let stored_proposer = Address::new([0x42; 20]); + let fallback_validator_set = validator_set(); + let expected_fallback = fallback_validator_set + .get_by_index(0) + .expect("test validator set is non-empty") + .address; + + let mut mock_eth = MockEthereumAPI::new(); + mock_eth + .expect_get_signing_validator_set() + .with(eq(2u64)) + .once() + .returning(move |_| Ok(fallback_validator_set.clone())); + + let engine = Engine::new(Box::new(MockEngineAPI::new()), Box::new(mock_eth)); + let metrics = AppMetrics::default(); + let ctx = ArcContext::default(); + + let result = get_certificates_info( + ctx.proposer_selector, + &engine, + &metrics, + vec![stored_cert(1, Some(stored_proposer)), stored_cert(2, None)], + ) + .await; + + let RangeQueryResult::Complete(infos) = result else { + panic!("expected complete range"); + }; + assert_eq!(infos[0].proposer, stored_proposer); + assert_eq!(infos[1].proposer, expected_fallback); } - /// At genesis (height 0), the saturating subtraction must keep the query at 0 rather - /// than underflowing. #[tokio::test] - async fn get_certificate_info_handles_genesis_height() { + async fn get_certificates_info_reports_failed_lookups_as_internal() { let mut mock_eth = MockEthereumAPI::new(); mock_eth - .expect_get_active_validator_set() - .with(eq(0u64)) + .expect_get_signing_validator_set() + .with(eq(2u64)) .once() - .returning(|_| Ok(Default::default())); + .returning(|_| Err(eyre!("validator set unavailable"))); let engine = Engine::new(Box::new(MockEngineAPI::new()), Box::new(mock_eth)); + let metrics = AppMetrics::default(); let ctx = ArcContext::default(); - let info = get_certificate_info(&ctx, &engine, stored_cert(0)) - .await - .expect("should return Some"); + let result = get_certificates_info( + ctx.proposer_selector, + &engine, + &metrics, + vec![ + stored_cert(1, Some(Address::new([0x42; 20]))), + stored_cert(2, None), + ], + ) + .await; - assert_eq!(info.certificate.height, Height::new(0)); + let RangeQueryResult::Unavailable { + reason, + failed_heights, + } = result + else { + panic!("expected unavailable range"); + }; + assert_eq!(reason, RangeFailureReason::Internal); + assert_eq!(failed_heights, vec![Height::new(2)]); } } diff --git a/crates/malachite-app/src/config.rs b/crates/malachite-app/src/config.rs index b59ab33a..4bc13181 100644 --- a/crates/malachite-app/src/config.rs +++ b/crates/malachite-app/src/config.rs @@ -197,7 +197,7 @@ impl StartConfig { /// Returns the default RPC sync endpoint for the given chain ID. fn default_rpc_sync_endpoint(chain_id: u64) -> eyre::Result { let url = match chain_id { - TESTNET_CHAIN_ID => "https://rpc.quicknode.testnet.arc.network/", + TESTNET_CHAIN_ID => "https://rpc.testnet.arc.io/", LOCALDEV_CHAIN_ID => "http://localhost:8545", _ => { return Err(eyre!( @@ -268,10 +268,7 @@ mod tests { #[test] fn default_rpc_sync_endpoint_testnet() { let endpoint = default_rpc_sync_endpoint(TESTNET_CHAIN_ID).unwrap(); - assert_eq!( - endpoint.http().as_str(), - "https://rpc.quicknode.testnet.arc.network/" - ); + assert_eq!(endpoint.http().as_str(), "https://rpc.testnet.arc.io/"); } #[test] @@ -317,7 +314,7 @@ mod tests { assert_eq!(config.rpc_sync_endpoints.len(), 1); assert_eq!( config.rpc_sync_endpoints[0].http().as_str(), - "https://rpc.quicknode.testnet.arc.network/" + "https://rpc.testnet.arc.io/" ); } diff --git a/crates/malachite-app/src/env_config.rs b/crates/malachite-app/src/env_config.rs index c59d93ff..cd2f1d68 100644 --- a/crates/malachite-app/src/env_config.rs +++ b/crates/malachite-app/src/env_config.rs @@ -19,11 +19,24 @@ use std::time::Duration; use arc_consensus_types::Height; use bytesize::ByteSize; +// Environment variables read at startup; leaving one unset keeps its hardcoded default. const ARC_HALT_AT_BLOCK_HEIGHT: &str = "ARC_HALT_AT_BLOCK_HEIGHT"; const ARC_CONSENSUS_DB_CACHE_SIZE_BYTES: &str = "ARC_CONSENSUS_DB_CACHE_SIZE_BYTES"; const ARC_SYNC_STATUS_UPDATE_INTERVAL: &str = "ARC_SYNC_STATUS_UPDATE_INTERVAL"; const ARC_SYNC_CATCH_UP_THRESHOLD: &str = "ARC_SYNC_CATCH_UP_THRESHOLD"; const ARC_GENESIS_FILE_PATH: &str = "ARC_GENESIS_FILE_PATH"; +const ARC_SYNC_REQUEST_TIMEOUT: &str = "ARC_SYNC_REQUEST_TIMEOUT"; +const ARC_SYNC_MAX_REQUEST_SIZE: &str = "ARC_SYNC_MAX_REQUEST_SIZE"; +const ARC_SYNC_MAX_RESPONSE_SIZE: &str = "ARC_SYNC_MAX_RESPONSE_SIZE"; +const ARC_SYNC_PARALLEL_REQUESTS: &str = "ARC_SYNC_PARALLEL_REQUESTS"; +const ARC_SYNC_INACTIVE_THRESHOLD: &str = "ARC_SYNC_INACTIVE_THRESHOLD"; +const ARC_SYNC_BATCH_SIZE: &str = "ARC_SYNC_BATCH_SIZE"; +const ARC_CONSENSUS_WAL_REPLAY_DELAY: &str = "ARC_CONSENSUS_WAL_REPLAY_DELAY"; +const ARC_CONSENSUS_QUEUE_CAPACITY: &str = "ARC_CONSENSUS_QUEUE_CAPACITY"; +const ARC_CONSENSUS_QUEUE_PER_HEIGHT_CAPACITY: &str = "ARC_CONSENSUS_QUEUE_PER_HEIGHT_CAPACITY"; +const ARC_DISCOVERY_EPHEMERAL_CONNECTION_TIMEOUT: &str = + "ARC_DISCOVERY_EPHEMERAL_CONNECTION_TIMEOUT"; +const ARC_REMOTE_SIGNING_TIMEOUT: &str = "ARC_REMOTE_SIGNING_TIMEOUT"; /// Default cache size for the database (1 GiB). const DEFAULT_DB_CACHE_SIZE: ByteSize = ByteSize::gib(1); @@ -47,46 +60,141 @@ pub struct EnvConfig { pub sync_catch_up_threshold: Duration, /// Path to the EL genesis.json file (for reading hardfork activation conditions). pub genesis_file_path: Option, + /// Overrides `value_sync::REQUEST_TIMEOUT` if set. + pub value_sync_request_timeout: Option, + /// Overrides `value_sync::MAX_REQUEST_SIZE` if set. + pub value_sync_max_request_size: Option, + /// Overrides `value_sync::MAX_RESPONSE_SIZE` if set. + pub value_sync_max_response_size: Option, + /// Overrides `value_sync::PARALLEL_REQUESTS` if set. + pub value_sync_parallel_requests: Option, + /// Overrides `value_sync::INACTIVE_THRESHOLD` if set. + pub value_sync_inactive_threshold: Option, + /// Overrides `value_sync::BATCH_SIZE` if set. + pub value_sync_batch_size: Option, + /// Overrides `consensus::WAL_REPLAY_DELAY` if set. + pub wal_replay_delay: Option, + /// Overrides `consensus::QUEUE_CAPACITY` if set. + pub queue_capacity: Option, + /// Overrides `consensus::QUEUE_PER_HEIGHT_CAPACITY` if set. + pub queue_per_height_capacity: Option, + /// Overrides `discovery::EPHEMERAL_CONNECTION_TIMEOUT` if set. + pub ephemeral_connection_timeout: Option, + /// Overrides `remote_signing::TIMEOUT` if set (remote signing only). + pub remote_signing_timeout: Option, +} + +/// Read an environment variable, returning `None` when it is unset or empty. +/// An empty value is treated as "unset" rather than as a parse error. +fn env_var_opt(key: &str) -> Option { + std::env::var(key).ok().filter(|s| !s.is_empty()) +} + +/// Parse an environment variable as a `humantime` duration (e.g. `"5s"`, `"500ms"`). +/// +/// Returns `Ok(None)` if unset, `Ok(Some(_))` if it parses, and `Err` if it is +/// set to a value that cannot be parsed. +fn env_duration(key: &str) -> eyre::Result> { + match env_var_opt(key) { + None => Ok(None), + Some(s) => humantime::parse_duration(&s) + .map(Some) + .map_err(|e| eyre::eyre!("{key}: invalid duration {s:?}: {e}")), + } +} + +/// Parse an environment variable via its `FromStr` impl. +/// +/// Returns `Ok(None)` if unset, `Ok(Some(_))` if it parses, and `Err` if it is +/// set to a value that cannot be parsed. +fn env_parse(key: &str) -> eyre::Result> { + match env_var_opt(key) { + None => Ok(None), + Some(s) => s + .parse::() + .map(Some) + .map_err(|_| eyre::eyre!("{key}: invalid value {s:?}")), + } +} + +/// Reject an explicit zero for a value that must be strictly positive. +/// +/// Parsing already rejects unparseable input; this additionally fails startup on +/// a parseable-but-nonsensical zero rather than silently substituting the +/// hardcoded default, so a misconfigured variable is surfaced instead of leaving +/// the operator under the impression their setting took effect. +fn reject_zero( + key: &str, + value: Option, + is_zero: impl Fn(&T) -> bool, +) -> eyre::Result> { + if let Some(v) = value.as_ref() { + if is_zero(v) { + eyre::bail!("{key}: must be greater than 0"); + } + } + Ok(value) +} + +/// Parse an environment variable as a `usize` that must be strictly positive. +fn env_nonzero_usize(key: &str) -> eyre::Result> { + reject_zero(key, env_parse::(key)?, |&n| n == 0) +} + +/// Parse an environment variable as a `ByteSize` that must be strictly positive. +fn env_nonzero_bytesize(key: &str) -> eyre::Result> { + reject_zero(key, env_parse::(key)?, |b| b.as_u64() == 0) +} + +/// Parse an environment variable as a `humantime` duration that must be non-zero. +/// +/// For timeouts/thresholds a zero duration is nonsensical, so it is rejected. +/// Durations where zero is a valid setting use `env_duration` instead. +fn env_nonzero_duration(key: &str) -> eyre::Result> { + reject_zero(key, env_duration(key)?, Duration::is_zero) } impl EnvConfig { /// Read configuration from environment variables. /// - /// - `ARC_HALT_AT_BLOCK_HEIGHT`: parsed as `u64`; 0 and missing both mean *no halt*. - /// - `ARC_CONSENSUS_DB_CACHE_SIZE_BYTES`: parsed as `usize`; missing means 1 GiB. - /// - `ARC_SYNC_STATUS_UPDATE_INTERVAL`: parsed via `humantime` (e.g. `"5s"`, `"500ms"`, `"0s"`); - /// missing or unparseable means use the hardcoded default. - pub fn from_env() -> Self { - let halt_height = std::env::var(ARC_HALT_AT_BLOCK_HEIGHT) - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|&n| n != 0) - .map(Height::new); - - let db_cache_size = std::env::var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(DEFAULT_DB_CACHE_SIZE); - - let status_update_interval = std::env::var(ARC_SYNC_STATUS_UPDATE_INTERVAL) - .ok() - .and_then(|s| humantime::parse_duration(&s).ok()); - let sync_catch_up_threshold = std::env::var(ARC_SYNC_CATCH_UP_THRESHOLD) - .ok() - .and_then(|s| humantime::parse_duration(&s).ok()) - .unwrap_or(DEFAULT_SYNC_CATCH_UP_THRESHOLD); - - let genesis_file_path = std::env::var(ARC_GENESIS_FILE_PATH) - .ok() - .filter(|s| !s.is_empty()); - - Self { - halt_height, - db_cache_size, - status_update_interval, - sync_catch_up_threshold, - genesis_file_path, - } + /// A variable that is unset (or empty) falls back to its hardcoded default. + /// A variable that is *set to a value that cannot be parsed* is a hard error + /// — startup fails rather than silently using the default. + /// + /// Counts, byte sizes, and timeouts/thresholds must be strictly positive: an + /// explicit `0` is a hard error rather than a silent fallback to the default, + /// since a zero would break the buffer/sizing/timeout it controls. The few + /// durations where `0` is a valid setting are exempt (noted below). + /// + /// - `ARC_HALT_AT_BLOCK_HEIGHT`: parsed as `u64`; 0 and unset both mean *no halt*. + /// - `ARC_CONSENSUS_DB_CACHE_SIZE_BYTES`: parsed via `bytesize`; unset means 1 GiB; 0 rejected. + /// - `ARC_SYNC_STATUS_UPDATE_INTERVAL`: parsed via `humantime`; `0s` means *update every block*. + /// - `ARC_CONSENSUS_WAL_REPLAY_DELAY`: parsed via `humantime`; `0s` means *no replay delay*. + pub fn from_env() -> eyre::Result { + Ok(Self { + halt_height: env_parse::(ARC_HALT_AT_BLOCK_HEIGHT)? + .filter(|&n| n != 0) + .map(Height::new), + db_cache_size: env_nonzero_bytesize(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES)? + .unwrap_or(DEFAULT_DB_CACHE_SIZE), + status_update_interval: env_duration(ARC_SYNC_STATUS_UPDATE_INTERVAL)?, + sync_catch_up_threshold: env_nonzero_duration(ARC_SYNC_CATCH_UP_THRESHOLD)? + .unwrap_or(DEFAULT_SYNC_CATCH_UP_THRESHOLD), + genesis_file_path: env_var_opt(ARC_GENESIS_FILE_PATH), + value_sync_request_timeout: env_nonzero_duration(ARC_SYNC_REQUEST_TIMEOUT)?, + value_sync_max_request_size: env_nonzero_bytesize(ARC_SYNC_MAX_REQUEST_SIZE)?, + value_sync_max_response_size: env_nonzero_bytesize(ARC_SYNC_MAX_RESPONSE_SIZE)?, + value_sync_parallel_requests: env_nonzero_usize(ARC_SYNC_PARALLEL_REQUESTS)?, + value_sync_inactive_threshold: env_nonzero_duration(ARC_SYNC_INACTIVE_THRESHOLD)?, + value_sync_batch_size: env_nonzero_usize(ARC_SYNC_BATCH_SIZE)?, + wal_replay_delay: env_duration(ARC_CONSENSUS_WAL_REPLAY_DELAY)?, + queue_capacity: env_nonzero_usize(ARC_CONSENSUS_QUEUE_CAPACITY)?, + queue_per_height_capacity: env_nonzero_usize(ARC_CONSENSUS_QUEUE_PER_HEIGHT_CAPACITY)?, + ephemeral_connection_timeout: env_nonzero_duration( + ARC_DISCOVERY_EPHEMERAL_CONNECTION_TIMEOUT, + )?, + remote_signing_timeout: env_nonzero_duration(ARC_REMOTE_SIGNING_TIMEOUT)?, + }) } } @@ -98,6 +206,17 @@ impl Default for EnvConfig { status_update_interval: None, sync_catch_up_threshold: DEFAULT_SYNC_CATCH_UP_THRESHOLD, genesis_file_path: None, + value_sync_request_timeout: None, + value_sync_max_request_size: None, + value_sync_max_response_size: None, + value_sync_parallel_requests: None, + value_sync_inactive_threshold: None, + value_sync_batch_size: None, + wal_replay_delay: None, + queue_capacity: None, + queue_per_height_capacity: None, + ephemeral_connection_timeout: None, + remote_signing_timeout: None, } } } @@ -108,49 +227,52 @@ mod tests { use super::*; + /// Every `ARC_*` variable this module reads, so the guard can snapshot, + /// clear, and restore them around each `#[serial]` test. + const MANAGED_VARS: &[&str] = &[ + ARC_HALT_AT_BLOCK_HEIGHT, + ARC_CONSENSUS_DB_CACHE_SIZE_BYTES, + ARC_SYNC_STATUS_UPDATE_INTERVAL, + ARC_SYNC_CATCH_UP_THRESHOLD, + ARC_GENESIS_FILE_PATH, + ARC_SYNC_REQUEST_TIMEOUT, + ARC_SYNC_MAX_REQUEST_SIZE, + ARC_SYNC_MAX_RESPONSE_SIZE, + ARC_SYNC_PARALLEL_REQUESTS, + ARC_SYNC_INACTIVE_THRESHOLD, + ARC_SYNC_BATCH_SIZE, + ARC_CONSENSUS_WAL_REPLAY_DELAY, + ARC_CONSENSUS_QUEUE_CAPACITY, + ARC_CONSENSUS_QUEUE_PER_HEIGHT_CAPACITY, + ARC_DISCOVERY_EPHEMERAL_CONNECTION_TIMEOUT, + ARC_REMOTE_SIGNING_TIMEOUT, + ]; + + /// Snapshots and clears all managed env vars on construction, restoring them on drop. struct EnvGuard { - old_halt: Option, - old_cache: Option, - old_status_interval: Option, - old_genesis: Option, + saved: Vec<(&'static str, Option)>, } impl EnvGuard { fn new() -> Self { - let old_halt = std::env::var(ARC_HALT_AT_BLOCK_HEIGHT).ok(); - let old_cache = std::env::var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES).ok(); - let old_status_interval = std::env::var(ARC_SYNC_STATUS_UPDATE_INTERVAL).ok(); - let old_genesis = std::env::var(ARC_GENESIS_FILE_PATH).ok(); - Self { - old_halt, - old_cache, - old_status_interval, - old_genesis, + let saved = MANAGED_VARS + .iter() + .map(|&key| (key, std::env::var(key).ok())) + .collect(); + for &key in MANAGED_VARS { + unsafe { std::env::remove_var(key) }; } + Self { saved } } } impl Drop for EnvGuard { fn drop(&mut self) { - if let Some(ref val) = self.old_halt { - unsafe { std::env::set_var(ARC_HALT_AT_BLOCK_HEIGHT, val) }; - } else { - unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; - } - if let Some(ref val) = self.old_cache { - unsafe { std::env::set_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES, val) }; - } else { - unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; - } - if let Some(ref val) = self.old_status_interval { - unsafe { std::env::set_var(ARC_SYNC_STATUS_UPDATE_INTERVAL, val) }; - } else { - unsafe { std::env::remove_var(ARC_SYNC_STATUS_UPDATE_INTERVAL) }; - } - if let Some(ref val) = self.old_genesis { - unsafe { std::env::set_var(ARC_GENESIS_FILE_PATH, val) }; - } else { - unsafe { std::env::remove_var(ARC_GENESIS_FILE_PATH) }; + for (key, val) in &self.saved { + match val { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } } } } @@ -163,7 +285,7 @@ mod tests { let _guard = EnvGuard::new(); unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.halt_height, None); } @@ -172,9 +294,7 @@ mod tests { fn test_env_halt_height_invalid_value() { let _guard = EnvGuard::new(); unsafe { std::env::set_var(ARC_HALT_AT_BLOCK_HEIGHT, "not_a_number") }; - unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; - let cfg = EnvConfig::from_env(); - assert_eq!(cfg.halt_height, None); + assert!(EnvConfig::from_env().is_err()); } #[test] @@ -183,7 +303,7 @@ mod tests { let _guard = EnvGuard::new(); unsafe { std::env::set_var(ARC_HALT_AT_BLOCK_HEIGHT, "0") }; unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.halt_height, None); } @@ -193,7 +313,7 @@ mod tests { let _guard = EnvGuard::new(); unsafe { std::env::set_var(ARC_HALT_AT_BLOCK_HEIGHT, "12345") }; unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.halt_height, Some(Height::new(12345))); } @@ -205,7 +325,7 @@ mod tests { let _guard = EnvGuard::new(); unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.db_cache_size, DEFAULT_DB_CACHE_SIZE); } @@ -215,7 +335,7 @@ mod tests { let _guard = EnvGuard::new(); unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; unsafe { std::env::set_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES, "2048") }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.db_cache_size, ByteSize::b(2048)); } @@ -223,10 +343,8 @@ mod tests { #[serial] fn test_env_db_cache_size_invalid_value() { let _guard = EnvGuard::new(); - unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; unsafe { std::env::set_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES, "not_a_number") }; - let cfg = EnvConfig::from_env(); - assert_eq!(cfg.db_cache_size, DEFAULT_DB_CACHE_SIZE); + assert!(EnvConfig::from_env().is_err()); } // status_update_interval tests @@ -238,7 +356,7 @@ mod tests { unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; unsafe { std::env::remove_var(ARC_SYNC_STATUS_UPDATE_INTERVAL) }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.status_update_interval, None); } @@ -249,7 +367,7 @@ mod tests { unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; unsafe { std::env::set_var(ARC_SYNC_STATUS_UPDATE_INTERVAL, "5s") }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.status_update_interval, Some(Duration::from_secs(5))); } @@ -260,7 +378,7 @@ mod tests { unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; unsafe { std::env::set_var(ARC_SYNC_STATUS_UPDATE_INTERVAL, "500ms") }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.status_update_interval, Some(Duration::from_millis(500))); } @@ -271,7 +389,7 @@ mod tests { unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; unsafe { std::env::set_var(ARC_SYNC_STATUS_UPDATE_INTERVAL, "0s") }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.status_update_interval, Some(Duration::ZERO)); } @@ -279,11 +397,8 @@ mod tests { #[serial] fn test_env_status_update_interval_invalid() { let _guard = EnvGuard::new(); - unsafe { std::env::remove_var(ARC_HALT_AT_BLOCK_HEIGHT) }; - unsafe { std::env::remove_var(ARC_CONSENSUS_DB_CACHE_SIZE_BYTES) }; unsafe { std::env::set_var(ARC_SYNC_STATUS_UPDATE_INTERVAL, "not_a_duration") }; - let cfg = EnvConfig::from_env(); - assert_eq!(cfg.status_update_interval, None); + assert!(EnvConfig::from_env().is_err()); } // genesis_file_path tests @@ -293,7 +408,7 @@ mod tests { fn test_env_genesis_file_path_not_set() { let _guard = EnvGuard::new(); unsafe { std::env::remove_var(ARC_GENESIS_FILE_PATH) }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.genesis_file_path, None); } @@ -302,7 +417,7 @@ mod tests { fn test_env_genesis_file_path_valid_value() { let _guard = EnvGuard::new(); unsafe { std::env::set_var(ARC_GENESIS_FILE_PATH, "/app/assets/genesis.json") }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!( cfg.genesis_file_path, Some("/app/assets/genesis.json".to_string()) @@ -314,10 +429,149 @@ mod tests { fn test_env_genesis_file_path_empty_string() { let _guard = EnvGuard::new(); unsafe { std::env::set_var(ARC_GENESIS_FILE_PATH, "") }; - let cfg = EnvConfig::from_env(); + let cfg = EnvConfig::from_env().unwrap(); assert_eq!(cfg.genesis_file_path, None); } + // operational tunable tests + + #[test] + #[serial] + fn test_env_operational_tunables_unset() { + let _guard = EnvGuard::new(); + let cfg = EnvConfig::from_env().unwrap(); + assert_eq!(cfg.value_sync_request_timeout, None); + assert_eq!(cfg.value_sync_max_request_size, None); + assert_eq!(cfg.value_sync_max_response_size, None); + assert_eq!(cfg.value_sync_parallel_requests, None); + assert_eq!(cfg.value_sync_inactive_threshold, None); + assert_eq!(cfg.value_sync_batch_size, None); + assert_eq!(cfg.wal_replay_delay, None); + assert_eq!(cfg.queue_capacity, None); + assert_eq!(cfg.queue_per_height_capacity, None); + assert_eq!(cfg.ephemeral_connection_timeout, None); + assert_eq!(cfg.remote_signing_timeout, None); + } + + #[test] + #[serial] + fn test_env_operational_tunables_all_set() { + let _guard = EnvGuard::new(); + unsafe { + std::env::set_var(ARC_SYNC_REQUEST_TIMEOUT, "2s"); + std::env::set_var(ARC_SYNC_MAX_REQUEST_SIZE, "2 MiB"); + std::env::set_var(ARC_SYNC_MAX_RESPONSE_SIZE, "20 MiB"); + std::env::set_var(ARC_SYNC_PARALLEL_REQUESTS, "7"); + std::env::set_var(ARC_SYNC_INACTIVE_THRESHOLD, "90s"); + std::env::set_var(ARC_SYNC_BATCH_SIZE, "25"); + std::env::set_var(ARC_CONSENSUS_WAL_REPLAY_DELAY, "3s"); + std::env::set_var(ARC_CONSENSUS_QUEUE_CAPACITY, "32"); + std::env::set_var(ARC_CONSENSUS_QUEUE_PER_HEIGHT_CAPACITY, "750"); + std::env::set_var(ARC_DISCOVERY_EPHEMERAL_CONNECTION_TIMEOUT, "8s"); + std::env::set_var(ARC_REMOTE_SIGNING_TIMEOUT, "45s"); + } + let cfg = EnvConfig::from_env().unwrap(); + assert_eq!(cfg.value_sync_request_timeout, Some(Duration::from_secs(2))); + assert_eq!(cfg.value_sync_max_request_size, Some(ByteSize::mib(2))); + assert_eq!(cfg.value_sync_max_response_size, Some(ByteSize::mib(20))); + assert_eq!(cfg.value_sync_parallel_requests, Some(7)); + assert_eq!( + cfg.value_sync_inactive_threshold, + Some(Duration::from_secs(90)) + ); + assert_eq!(cfg.value_sync_batch_size, Some(25)); + assert_eq!(cfg.wal_replay_delay, Some(Duration::from_secs(3))); + assert_eq!(cfg.queue_capacity, Some(32)); + assert_eq!(cfg.queue_per_height_capacity, Some(750)); + assert_eq!( + cfg.ephemeral_connection_timeout, + Some(Duration::from_secs(8)) + ); + assert_eq!(cfg.remote_signing_timeout, Some(Duration::from_secs(45))); + } + + #[test] + #[serial] + fn test_env_tunable_invalid_values_error() { + let _guard = EnvGuard::new(); + + // An unparseable value for each parse kind (duration / byte size / count) + // fails startup rather than silently falling back to the default. + unsafe { std::env::set_var(ARC_SYNC_REQUEST_TIMEOUT, "not_a_duration") }; + assert!(EnvConfig::from_env().is_err()); + unsafe { std::env::remove_var(ARC_SYNC_REQUEST_TIMEOUT) }; + + unsafe { std::env::set_var(ARC_SYNC_MAX_REQUEST_SIZE, "not_a_size") }; + assert!(EnvConfig::from_env().is_err()); + unsafe { std::env::remove_var(ARC_SYNC_MAX_REQUEST_SIZE) }; + + unsafe { std::env::set_var(ARC_CONSENSUS_QUEUE_CAPACITY, "not_a_number") }; + assert!(EnvConfig::from_env().is_err()); + } + + #[test] + #[serial] + fn test_env_zero_counts_and_byte_sizes_error() { + let _guard = EnvGuard::new(); + + // A zero count or byte size is nonsensical for the buffer/sizing it + // controls, so it fails startup rather than silently using the default. + for key in [ + ARC_SYNC_PARALLEL_REQUESTS, + ARC_SYNC_BATCH_SIZE, + ARC_CONSENSUS_QUEUE_CAPACITY, + ARC_CONSENSUS_QUEUE_PER_HEIGHT_CAPACITY, + ARC_SYNC_MAX_REQUEST_SIZE, + ARC_SYNC_MAX_RESPONSE_SIZE, + ARC_CONSENSUS_DB_CACHE_SIZE_BYTES, + ] { + unsafe { std::env::set_var(key, "0") }; + assert!( + EnvConfig::from_env().is_err(), + "{key}=0 should fail startup" + ); + unsafe { std::env::remove_var(key) }; + } + } + + #[test] + #[serial] + fn test_env_zero_timeouts_error() { + let _guard = EnvGuard::new(); + + // A zero timeout/threshold is nonsensical, so it fails startup rather + // than silently using the default. + for key in [ + ARC_SYNC_CATCH_UP_THRESHOLD, + ARC_SYNC_REQUEST_TIMEOUT, + ARC_SYNC_INACTIVE_THRESHOLD, + ARC_DISCOVERY_EPHEMERAL_CONNECTION_TIMEOUT, + ARC_REMOTE_SIGNING_TIMEOUT, + ] { + unsafe { std::env::set_var(key, "0s") }; + assert!( + EnvConfig::from_env().is_err(), + "{key}=0s should fail startup" + ); + unsafe { std::env::remove_var(key) }; + } + } + + #[test] + #[serial] + fn test_env_zero_durations_allowed_where_meaningful() { + let _guard = EnvGuard::new(); + + // `0` is a valid setting for these durations and must be preserved. + unsafe { + std::env::set_var(ARC_SYNC_STATUS_UPDATE_INTERVAL, "0s"); + std::env::set_var(ARC_CONSENSUS_WAL_REPLAY_DELAY, "0s"); + } + let cfg = EnvConfig::from_env().unwrap(); + assert_eq!(cfg.status_update_interval, Some(Duration::ZERO)); + assert_eq!(cfg.wal_replay_delay, Some(Duration::ZERO)); + } + #[test] #[serial] fn test_env_default() { @@ -327,5 +581,16 @@ mod tests { assert_eq!(cfg.db_cache_size, DEFAULT_DB_CACHE_SIZE); assert_eq!(cfg.status_update_interval, None); assert_eq!(cfg.genesis_file_path, None); + assert_eq!(cfg.value_sync_request_timeout, None); + assert_eq!(cfg.value_sync_max_request_size, None); + assert_eq!(cfg.value_sync_max_response_size, None); + assert_eq!(cfg.value_sync_parallel_requests, None); + assert_eq!(cfg.value_sync_inactive_threshold, None); + assert_eq!(cfg.value_sync_batch_size, None); + assert_eq!(cfg.wal_replay_delay, None); + assert_eq!(cfg.queue_capacity, None); + assert_eq!(cfg.queue_per_height_capacity, None); + assert_eq!(cfg.ephemeral_connection_timeout, None); + assert_eq!(cfg.remote_signing_timeout, None); } } diff --git a/crates/malachite-app/src/finalize.rs b/crates/malachite-app/src/finalize.rs index 3bdd6df3..80f022df 100644 --- a/crates/malachite-app/src/finalize.rs +++ b/crates/malachite-app/src/finalize.rs @@ -193,7 +193,7 @@ mod tests { // Engine API spec: forkchoice_updated returns latest_valid_hash == head block hash mock_engine .expect_forkchoice_updated() - .returning(move |_, _| { + .returning(move |_, _, _| { Ok(ForkchoiceUpdated { payload_status: PayloadStatus { status: PayloadStatusEnum::Valid, diff --git a/crates/malachite-app/src/handlers/consensus_ready.rs b/crates/malachite-app/src/handlers/consensus_ready.rs index 5c3655b4..c17bebd4 100644 --- a/crates/malachite-app/src/handlers/consensus_ready.rs +++ b/crates/malachite-app/src/handlers/consensus_ready.rs @@ -14,6 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use alloy_rpc_types_eth::BlockNumberOrTag; use eyre::{eyre, Context}; use std::time::Duration; use tracing::{debug, error, info, warn}; @@ -30,8 +31,10 @@ use malachitebft_app_channel::Reply; use malachitebft_core_types::HeightParams; use crate::finalize::{BlockFinalizer, EngineBlockFinalizer}; -use crate::metrics::AppMetrics; -use crate::payload::{EnginePayloadValidator, PayloadValidationResult, PayloadValidator}; +use crate::metrics::{AppMetrics, BindingHaltSite}; +use crate::payload::{ + check_payload_binding, EnginePayloadValidator, PayloadValidationResult, PayloadValidator, +}; use crate::state::State; use crate::store::repositories::{ CertificatesRepository, PayloadsRepository, PendingProposalsRepository, @@ -140,11 +143,11 @@ async fn on_consensus_ready( ) .await?; - // The validator set for the next height is the one at the latest committed height + // The validator set for the next height is the one committed at latest_height. let validator_set = ethereum_api - .get_active_validator_set(latest_height.as_u64()) + .get_signing_validator_set(next_height.as_u64()) .await - .wrap_err("Failed to get the validator set at ConsensusReady")?; + .wrap_err("Failed to get the signing validator set at ConsensusReady")?; // The consensus params for the next height are the same as the latest committed height let consensus_params = ethereum_api @@ -188,9 +191,11 @@ impl From for ReplayBlockError { /// [`InvalidPayload`][crate::invalid_payloads::InvalidPayload]. async fn replay_block( height: Height, + previous_block: &ExecutionBlock, payloads_repository: impl PayloadsRepository, payload_validator: impl PayloadValidator, block_finalizer: impl BlockFinalizer, + metrics: &AppMetrics, ) -> Result { info!("🔄 Replay: replaying block at height {height} from Consensus to Execution Client"); @@ -202,6 +207,18 @@ async fn replay_block( let payload_hash = payload.payload_inner.payload_inner.block_hash; + // The handshake takes the consensus position from the block number of the + // replayed payload, so a payload that does not chain to `previous_block` + // moves that position. Stop instead. + if let Err(error) = check_payload_binding(&payload, height, Some(previous_block)) { + metrics.inc_binding_halt_count(BindingHaltSite::Replay); + + return Err(eyre!( + "Replay: payload {payload_hash} is not bound to its place in the chain at height {height}: {error}" + ) + .into()); + } + // EngineAPI: New payload let result = payload_validator .validate_payload(&payload) @@ -253,7 +270,11 @@ async fn checkpoint_sync( info!(block_hash = %target_block_hash, height = %target_block_height, timeout = ?timeout, "🔄 Checkpoint sync: targeting block hash"); let fcu_result = engine_api - .forkchoice_updated(target_block_hash, None) + .forkchoice_updated( + target_block_hash, + None, + arc_eth_engine::ENGINE_FORKCHOICE_UPDATED_TIMEOUT, + ) .await .wrap_err("Checkpoint sync: forkchoice_updated failed")?; @@ -263,9 +284,8 @@ async fn checkpoint_sync( } PayloadStatusEnum::Valid => { info!(block_hash = %target_block_hash, height = %target_block_height, "🔄 Checkpoint sync: EL already has the target block"); - let height_str = format!("0x{:x}", target_block_height.as_u64()); return ethereum_api - .get_block_by_number(&height_str) + .get_block_by_number(BlockNumberOrTag::Number(target_block_height.as_u64())) .await? .ok_or_else(|| { eyre!( @@ -280,7 +300,7 @@ async fn checkpoint_sync( const POLL_INTERVAL: Duration = Duration::from_secs(2); let start = tokio::time::Instant::now(); - let height_str = format!("0x{:x}", target_block_height.as_u64()); + let block_number = BlockNumberOrTag::Number(target_block_height.as_u64()); loop { tokio::time::sleep(POLL_INTERVAL).await; @@ -289,7 +309,7 @@ async fn checkpoint_sync( return Err(eyre!("Checkpoint sync: timed out after {timeout:?}")); } - let block = ethereum_api.get_block_by_number(&height_str).await?; + let block = ethereum_api.get_block_by_number(block_number).await?; if let Some(b) = block { if b.block_hash == target_block_hash { info!( @@ -350,7 +370,7 @@ async fn handshake_and_replay( let _guard = metrics.start_engine_api_timer("get_block_by_number"); ethereum_api - .get_block_by_number("latest") + .get_block_by_number(BlockNumberOrTag::Latest) .await? .ok_or_else(|| { eyre::eyre!("Handshake: Could not get latest block from execution client") @@ -391,11 +411,15 @@ async fn handshake_and_replay( // fall back to checkpoint sync using the block hash from the CL's latest certificate. let mut replay_height = latest_height_el.increment(); while replay_height <= latest_height_cons { + let previous_block = latest_block; + match replay_block( replay_height, + &previous_block, &payloads_repository, &payload_validator, &block_finalizer, + metrics, ) .await { @@ -592,20 +616,20 @@ mod tests { fn setup_mock_ethereum_api_with_block( block: ExecutionBlock, - validator_set_height: u64, + latest_height: u64, ) -> MockEthereumAPI { let mut ethereum_api = MockEthereumAPI::new(); ethereum_api .expect_get_block_by_number() - .with(eq("latest")) + .with(eq(BlockNumberOrTag::Latest)) .returning(move |_| Ok(Some(block))); ethereum_api - .expect_get_active_validator_set() - .with(eq(validator_set_height)) + .expect_get_signing_validator_set() + .with(eq(latest_height + 1)) .returning(|_| Ok(test_validator_set())); ethereum_api .expect_get_consensus_params() - .with(eq(validator_set_height)) + .with(eq(latest_height)) .returning(|_| Ok(test_consensus_params())); ethereum_api } @@ -614,7 +638,7 @@ mod tests { let mut ethereum_api = MockEthereumAPI::new(); ethereum_api .expect_get_block_by_number() - .with(eq("latest")) + .with(eq(BlockNumberOrTag::Latest)) .returning(|_| Ok(None)); ethereum_api } @@ -671,6 +695,60 @@ mod tests { } // Test 2: Single block replay (EL + 1 == CL) + /// A stored payload that reports another block number moves the consensus + /// position the handshake derives from it, so the handshake stops instead. + #[tokio::test] + async fn replay_stops_on_a_payload_that_reports_another_height() { + let el_height = 4u64; + let cl_height = Height::new(el_height + 1); + let latest_block_el = test_execution_block(el_height, 1000); + + // The payload for height 5 reports block number 2. + let payload_to_replay = test_payload(2, 1100); + + let mut payload_validator = MockPayloadValidator::new(); + payload_validator.expect_validate_payload().times(0); + + let mut block_finalizer = MockBlockFinalizer::new(); + block_finalizer.expect_finalize_decided_block().times(0); + + let engine_api = setup_mock_engine_api_success(); + let ethereum_api = setup_mock_ethereum_api_with_block(latest_block_el, cl_height.as_u64()); + + let mut certificates_repo = MockCertificatesRepository::new(); + certificates_repo + .expect_max_height() + .return_once(move || Ok(Some(cl_height))); + + let mut payloads_repo = MockPayloadsRepository::new(); + payloads_repo + .expect_get() + .return_once(move |_| Ok(Some(payload_to_replay))); + + let metrics = test_metrics(); + + let error = handshake_and_replay( + &payload_validator, + &block_finalizer, + &engine_api, + ðereum_api, + &certificates_repo, + &payloads_repo, + NoopPersistenceMeter, + &metrics, + ) + .await + .expect_err("an unbound stored payload must stop the handshake"); + + assert!( + error + .to_string() + .contains("is not bound to its place in the chain"), + "got: {error:#}", + ); + assert_eq!(metrics.get_binding_halt_count(BindingHaltSite::Replay), 1); + } + #[tokio::test] async fn test_single_block_replay() { let el_height = 4u64; @@ -1559,11 +1637,11 @@ mod tests { let mut ethereum_api = MockEthereumAPI::new(); ethereum_api .expect_get_block_by_number() - .with(eq("latest")) + .with(eq(BlockNumberOrTag::Latest)) .returning(move |_| Ok(Some(latest_block))); ethereum_api - .expect_get_active_validator_set() - .with(eq(latest_height.as_u64())) + .expect_get_signing_validator_set() + .with(eq(latest_height.increment().as_u64())) .returning(|_| Ok(test_validator_set())); ethereum_api .expect_get_consensus_params() @@ -1635,7 +1713,7 @@ mod tests { let mut engine_api = setup_mock_engine_api_success(); engine_api .expect_forkchoice_updated() - .return_once(move |_, _| { + .return_once(move |_, _, _| { Ok(ForkchoiceUpdated::new(PayloadStatus::new( PayloadStatusEnum::Syncing, None, @@ -1646,16 +1724,16 @@ mod tests { // Handshake fetches "latest" for the initial EL height ethereum_api .expect_get_block_by_number() - .with(eq("latest")) + .with(eq(BlockNumberOrTag::Latest)) .returning(move |_| Ok(Some(latest_block_el))); // Checkpoint sync polls by target height ethereum_api .expect_get_block_by_number() - .with(eq("0xa")) + .with(eq(BlockNumberOrTag::Number(10))) .returning(move |_| Ok(Some(check_point_block))); ethereum_api - .expect_get_active_validator_set() - .with(eq(cl_height.as_u64())) + .expect_get_signing_validator_set() + .with(eq(cl_height.increment().as_u64())) .returning(|_| Ok(test_validator_set())); ethereum_api .expect_get_consensus_params() @@ -1736,7 +1814,7 @@ mod tests { let mut engine_api = MockEngineAPI::new(); engine_api .expect_forkchoice_updated() - .return_once(move |_, _| { + .return_once(move |_, _, _| { Ok(ForkchoiceUpdated::new(PayloadStatus::new( PayloadStatusEnum::Syncing, None, @@ -1747,7 +1825,7 @@ mod tests { let mut poll_count = 0u32; ethereum_api .expect_get_block_by_number() - .with(eq("0x32")) + .with(eq(BlockNumberOrTag::Number(50))) .returning(move |_| { poll_count += 1; if poll_count < 3 { @@ -1780,18 +1858,20 @@ mod tests { let target_hash = B256::repeat_byte(0xCC); let mut engine_api = MockEngineAPI::new(); - engine_api.expect_forkchoice_updated().return_once(|_, _| { - Ok(ForkchoiceUpdated::new(PayloadStatus::new( - PayloadStatusEnum::Syncing, - None, - ))) - }); + engine_api + .expect_forkchoice_updated() + .return_once(|_, _, _| { + Ok(ForkchoiceUpdated::new(PayloadStatus::new( + PayloadStatusEnum::Syncing, + None, + ))) + }); let mut ethereum_api = MockEthereumAPI::new(); // Block at target height is never available, forcing a timeout ethereum_api .expect_get_block_by_number() - .with(eq("0x32")) + .with(eq(BlockNumberOrTag::Number(50))) .returning(|_| Ok(None)); let timeout = Some(Duration::from_secs(10)); @@ -1816,14 +1896,16 @@ mod tests { let target_hash = B256::repeat_byte(0xDD); let mut engine_api = MockEngineAPI::new(); - engine_api.expect_forkchoice_updated().return_once(|_, _| { - Ok(ForkchoiceUpdated::new(PayloadStatus::new( - PayloadStatusEnum::Invalid { - validation_error: "block not found".to_string(), - }, - None, - ))) - }); + engine_api + .expect_forkchoice_updated() + .return_once(|_, _, _| { + Ok(ForkchoiceUpdated::new(PayloadStatus::new( + PayloadStatusEnum::Invalid { + validation_error: "block not found".to_string(), + }, + None, + ))) + }); let ethereum_api = MockEthereumAPI::new(); @@ -1851,18 +1933,19 @@ mod tests { let target_height = Height::new(50); let mut engine_api = MockEngineAPI::new(); - engine_api.expect_forkchoice_updated().return_once(|_, _| { - Ok(ForkchoiceUpdated::new(PayloadStatus::new( - PayloadStatusEnum::Syncing, - None, - ))) - }); + engine_api + .expect_forkchoice_updated() + .return_once(|_, _, _| { + Ok(ForkchoiceUpdated::new(PayloadStatus::new( + PayloadStatusEnum::Syncing, + None, + ))) + }); let mut ethereum_api = MockEthereumAPI::new(); - let height_str = format!("0x{:x}", target_height.as_u64()); ethereum_api .expect_get_block_by_number() - .with(eq(height_str)) + .with(eq(BlockNumberOrTag::Number(target_height.as_u64()))) .returning(move |_| { Ok(Some(ExecutionBlock { block_hash: wrong_hash, @@ -1897,7 +1980,7 @@ mod tests { let mut engine_api = MockEngineAPI::new(); engine_api .expect_forkchoice_updated() - .return_once(move |_, _| { + .return_once(move |_, _, _| { Ok(ForkchoiceUpdated::new(PayloadStatus::new( PayloadStatusEnum::Valid, None, @@ -1907,7 +1990,7 @@ mod tests { let mut ethereum_api = MockEthereumAPI::new(); ethereum_api .expect_get_block_by_number() - .with(eq("0x32")) + .with(eq(BlockNumberOrTag::Number(50))) .return_once(move |_| Ok(Some(target_block))); let result = diff --git a/crates/malachite-app/src/handlers/decided.rs b/crates/malachite-app/src/handlers/decided.rs index e1903b49..16004293 100644 --- a/crates/malachite-app/src/handlers/decided.rs +++ b/crates/malachite-app/src/handlers/decided.rs @@ -26,12 +26,14 @@ use arc_eth_engine::json_structures::ExecutionBlock; use crate::block::ConsensusBlock; use crate::finalize::{BlockFinalizer, EngineBlockFinalizer}; -use crate::metrics::AppMetrics; +use crate::metrics::{AppMetrics, BindingHaltSite}; +use crate::payload::check_payload_binding; use crate::state::{Decision, NextHeightInfo, State}; use crate::stats::Stats; use crate::store::repositories::{DecidedBlocksRepository, UndecidedBlocksRepository}; use crate::store::services::{ProdPruningService, PruningService}; use crate::utils::sync_state::{sync_state, SyncState}; +use crate::utils::HaltAndWait; /// Handles the `Decided` message from the consensus engine. /// @@ -64,6 +66,7 @@ pub async fn handle( store_proposal_monitor_on_decision(state, decided_height, &decided_value_id).await; + let previous_block = state.previous_block; let (store, metrics, stats) = (state.store(), state.metrics(), state.stats()); let block_finalizer = EngineBlockFinalizer::new(engine, stats, metrics); @@ -78,6 +81,7 @@ pub async fn handle( stats, metrics, commit_ack, + previous_block.as_ref(), ) .await; @@ -100,6 +104,13 @@ pub async fn handle( state.decision = Some(Decision::Success(Box::new(next_height_info))); } + // A chain anomaly shuts the node down. `state.decision` stays unset because + // the `Finalized` message that reads it never arrives. + Err(e) if e.downcast_ref::().is_some() => { + error!("🔴 Decided: chain anomaly, halting: {e:#}"); + + return Err(e); + } Err(e) => { error!("🔴 Failed to process decided value: {e:#}"); @@ -148,6 +159,7 @@ async fn decide( stats: &Stats, metrics: &AppMetrics, commit_ack: Reply<()>, + previous_block: Option<&ExecutionBlock>, ) -> eyre::Result { let height = certificate.height; let round = certificate.round; @@ -172,6 +184,32 @@ async fn decide( } }; + // Binding before the stored verdict. Every ingestion path rejects an unbound + // payload before consensus votes on it, so reaching this point means an + // invariant broke. A restart cannot change the certificate, so the node stops + // here whatever the row reads, rather than repeating the height with no reason. + if let Err(error) = check_payload_binding(&block.execution_payload, height, previous_block) { + error!( + %height, %round, %value_id, + "🛑 Chain anomaly: decided payload is not bound to its place in the chain; halting", + ); + + metrics.inc_binding_halt_count(BindingHaltSite::Decided); + + return Err(HaltAndWait::new(format!( + "decided payload is not bound to its place in the chain at height={height}, \ + round={round}, value_id={value_id}: {error}" + )) + .into()); + } + + // Only finalize a block the engine marked valid. + if !block.validity.is_valid() { + return Err(eyre!( + "Refusing to finalize undecided block marked invalid for certificate with height={height}, round={round}, value_id={value_id}" + )); + } + debug!( "🎁 Block size: {:?}, payload size: {:?}", block.size_bytes(), @@ -286,14 +324,13 @@ async fn prepare_next_height( ) -> eyre::Result { let next_height = decided_height.increment(); - // Fetch the validator set for the next height - // NOTE: Validator set is fetched at the decided height for the next height + // Fetch the signing validator set for the next consensus height. let validator_set = engine .eth - .get_active_validator_set(decided_height.as_u64()) + .get_signing_validator_set(next_height.as_u64()) .await .wrap_err_with(|| { - format!("Failed to fetch validator set at height {decided_height} for next height {next_height}") + format!("Failed to fetch signing validator set for next height {next_height}") })?; // Fetch the consensus params for the next height @@ -496,6 +533,7 @@ mod tests { &stats, &metrics, dummy_commit_ack(), + None, ) .await; @@ -504,6 +542,272 @@ mod tests { assert_eq!(block.timestamp, timestamp); } + /// Mocks that assert the decision never reaches the store or the engine. + fn expect_no_commit() -> ( + MockDecidedBlocksRepository, + MockBlockFinalizer, + MockPruningService, + ) { + let mut decided_blocks = MockDecidedBlocksRepository::new(); + decided_blocks.expect_store().times(0); + + let mut block_finalizer = MockBlockFinalizer::new(); + block_finalizer.expect_finalize_decided_block().times(0); + + (decided_blocks, block_finalizer, MockPruningService::new()) + } + + /// A payload that reports a block number below its certificate height: a + /// fresh block forked from an older ancestor, or an older payload re-proposed. + #[tokio::test] + async fn decide_halts_when_the_decided_payload_reports_another_height() { + let height = 11u64; + let round = 0u32; + let block_hash = B256::repeat_byte((height % 256) as u8); + let certificate = test_commit_certificate(height, round, block_hash); + + let mut consensus_block = test_consensus_block(height, round, 1000); + consensus_block + .execution_payload + .payload_inner + .payload_inner + .block_number = 5; + + let mut undecided_blocks = MockUndecidedBlocksRepository::new(); + undecided_blocks + .expect_get_by_hash() + .with(eq(Height::new(height)), eq(block_hash)) + .return_once(move |_, _| Ok(Some(consensus_block.clone()))); + + let (decided_blocks, block_finalizer, pruning_service) = expect_no_commit(); + let metrics = test_metrics(); + let stats = test_stats(); + + let error = decide( + block_finalizer, + undecided_blocks, + decided_blocks, + pruning_service, + certificate, + &stats, + &metrics, + dummy_commit_ack(), + Some(&test_execution_block(height - 1, 900)), + ) + .await + .expect_err("an unbound payload must not be finalized"); + + assert!( + error.downcast_ref::().is_some(), + "expected a fail-stop, got: {error:#}", + ); + assert!( + format!("{error:#}").contains("does not match consensus height"), + "the halt must name the broken rule, got: {error:#}", + ); + assert_eq!(metrics.get_binding_halt_count(BindingHaltSite::Decided), 1); + } + + #[tokio::test] + async fn decide_halts_when_the_decided_payload_does_not_extend_the_previous_block() { + let height = 11u64; + let round = 0u32; + let block_hash = B256::repeat_byte((height % 256) as u8); + let certificate = test_commit_certificate(height, round, block_hash); + + let mut consensus_block = test_consensus_block(height, round, 1000); + consensus_block + .execution_payload + .payload_inner + .payload_inner + .parent_hash = B256::repeat_byte(0xAB); + + let mut undecided_blocks = MockUndecidedBlocksRepository::new(); + undecided_blocks + .expect_get_by_hash() + .with(eq(Height::new(height)), eq(block_hash)) + .return_once(move |_, _| Ok(Some(consensus_block.clone()))); + + let (decided_blocks, block_finalizer, pruning_service) = expect_no_commit(); + let metrics = test_metrics(); + let stats = test_stats(); + + let error = decide( + block_finalizer, + undecided_blocks, + decided_blocks, + pruning_service, + certificate, + &stats, + &metrics, + dummy_commit_ack(), + Some(&test_execution_block(height - 1, 900)), + ) + .await + .expect_err("a payload that extends another block must not be finalized"); + + assert!( + error.downcast_ref::().is_some(), + "expected a fail-stop, got: {error:#}", + ); + assert!( + format!("{error:#}").contains("is not the block finalized at the previous height"), + "the halt must name the broken rule, got: {error:#}", + ); + assert_eq!(metrics.get_binding_halt_count(BindingHaltSite::Decided), 1); + } + + /// The reachable path: the ingestion rules already marked the row `Invalid`, + /// so the binding must be read before the stored verdict. Otherwise the plain + /// verdict error restarts the height and the anomaly carries no signal. + #[tokio::test] + async fn decide_halts_on_an_unbound_payload_whose_row_is_already_invalid() { + let height = 11u64; + let round = 0u32; + let block_hash = B256::repeat_byte((height % 256) as u8); + let certificate = test_commit_certificate(height, round, block_hash); + + let mut consensus_block = test_consensus_block(height, round, 1000); + consensus_block.validity = Validity::Invalid; + consensus_block + .execution_payload + .payload_inner + .payload_inner + .block_number = 5; + + let mut undecided_blocks = MockUndecidedBlocksRepository::new(); + undecided_blocks + .expect_get_by_hash() + .return_once(move |_, _| Ok(Some(consensus_block.clone()))); + + let (decided_blocks, block_finalizer, pruning_service) = expect_no_commit(); + let metrics = test_metrics(); + let stats = test_stats(); + + let error = decide( + block_finalizer, + undecided_blocks, + decided_blocks, + pruning_service, + certificate, + &stats, + &metrics, + dummy_commit_ack(), + Some(&test_execution_block(height - 1, 900)), + ) + .await + .expect_err("an unbound payload must not be finalized"); + + assert!( + error.downcast_ref::().is_some(), + "the binding must be read before the stored verdict, got: {error:#}", + ); + } + + /// A bound payload whose row reads `Invalid` keeps the ordinary verdict error, + /// so the reordering above did not turn every rejection into a fail-stop. + #[tokio::test] + async fn decide_does_not_halt_on_a_bound_payload_marked_invalid() { + let height = 11u64; + let round = 0u32; + let block_hash = B256::repeat_byte((height % 256) as u8); + let certificate = test_commit_certificate(height, round, block_hash); + + let mut consensus_block = test_consensus_block(height, round, 1000); + consensus_block.validity = Validity::Invalid; + + let mut undecided_blocks = MockUndecidedBlocksRepository::new(); + undecided_blocks + .expect_get_by_hash() + .return_once(move |_, _| Ok(Some(consensus_block.clone()))); + + let (decided_blocks, block_finalizer, pruning_service) = expect_no_commit(); + let metrics = test_metrics(); + let stats = test_stats(); + + let error = decide( + block_finalizer, + undecided_blocks, + decided_blocks, + pruning_service, + certificate, + &stats, + &metrics, + dummy_commit_ack(), + Some(&test_execution_block(height - 1, 900)), + ) + .await + .expect_err("a block marked invalid must not be finalized"); + + assert!( + error.downcast_ref::().is_none(), + "an engine rejection is not a chain anomaly, got: {error:#}", + ); + assert!( + error.to_string().contains("marked invalid"), + "got: {error:#}", + ); + } + + /// The same payload and previous block, with the binding intact: the + /// decision proceeds, so the tests above pin the binding and nothing else. + #[tokio::test] + async fn decide_commits_a_payload_that_extends_the_previous_block() { + let height = 11u64; + let round = 0u32; + let timestamp = 1000u64; + let block_hash = B256::repeat_byte((height % 256) as u8); + let certificate = test_commit_certificate(height, round, block_hash); + let consensus_block = test_consensus_block(height, round, timestamp); + + let mut undecided_blocks = MockUndecidedBlocksRepository::new(); + undecided_blocks + .expect_get_by_hash() + .return_once(move |_, _| Ok(Some(consensus_block.clone()))); + + let mut decided_blocks = MockDecidedBlocksRepository::new(); + decided_blocks + .expect_store() + .times(1) + .returning(|_, _, _| Ok(())); + + let mut block_finalizer = MockBlockFinalizer::new(); + block_finalizer + .expect_finalize_decided_block() + .times(1) + .return_once(move |_, _| Ok((test_execution_block(height, timestamp), block_hash))); + + let mut pruning_service = MockPruningService::new(); + pruning_service + .expect_clean_stale_consensus_data() + .return_once(|_| Ok(())); + pruning_service + .expect_prune_historical_certs() + .return_once(|_| Ok(vec![])); + pruning_service + .expect_prune_decided_blocks() + .return_once(|| Ok(vec![])); + + let metrics = test_metrics(); + let stats = test_stats(); + + let block = decide( + block_finalizer, + undecided_blocks, + decided_blocks, + pruning_service, + certificate, + &stats, + &metrics, + dummy_commit_ack(), + Some(&test_execution_block(height - 1, 900)), + ) + .await + .expect("a bound payload is finalized"); + + assert_eq!(block.block_number, height); + } + // Block not found in undecided blocks #[tokio::test] async fn test_decide_block_not_found() { @@ -533,6 +837,7 @@ mod tests { &stats, &metrics, dummy_commit_ack(), + None, ) .await; @@ -540,6 +845,50 @@ mod tests { assert!(err_msg.contains("Cannot find undecided block")); } + // A stored block marked invalid must never be finalized. + #[tokio::test] + async fn test_decide_rejects_invalid_block() { + let height = 5u64; + let round = 2u32; + let timestamp = 1000u64; + let block_hash = B256::repeat_byte((height % 256) as u8); + let certificate = test_commit_certificate(height, round, block_hash); + let mut consensus_block = test_consensus_block(height, round, timestamp); + consensus_block.validity = Validity::Invalid; + + let mut undecided_blocks = MockUndecidedBlocksRepository::new(); + undecided_blocks + .expect_get_by_hash() + .with(eq(Height::new(height)), eq(block_hash)) + .return_once(move |_, _| Ok(Some(consensus_block.clone()))); + + // Finalization and decided-block storage must not be reached. + let mut block_finalizer = MockBlockFinalizer::new(); + block_finalizer.expect_finalize_decided_block().times(0); + let mut decided_blocks = MockDecidedBlocksRepository::new(); + decided_blocks.expect_store().times(0); + let pruning_service = MockPruningService::new(); + + let metrics = test_metrics(); + let stats = test_stats(); + + let result = decide( + block_finalizer, + undecided_blocks, + decided_blocks, + pruning_service, + certificate, + &stats, + &metrics, + dummy_commit_ack(), + None, + ) + .await; + + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("marked invalid")); + } + // Repository error when fetching undecided block #[tokio::test] async fn test_decide_undecided_blocks_fetch_error() { @@ -569,6 +918,7 @@ mod tests { &stats, &metrics, dummy_commit_ack(), + None, ) .await; @@ -611,6 +961,7 @@ mod tests { &stats, &metrics, dummy_commit_ack(), + None, ) .await; diff --git a/crates/malachite-app/src/handlers/get_decided_values.rs b/crates/malachite-app/src/handlers/get_decided_values.rs index 8ae4a946..1dcd2014 100644 --- a/crates/malachite-app/src/handlers/get_decided_values.rs +++ b/crates/malachite-app/src/handlers/get_decided_values.rs @@ -17,6 +17,7 @@ use std::ops::RangeInclusive; use alloy_rpc_types_engine::ExecutionPayloadV3; +use alloy_rpc_types_eth::BlockNumberOrTag; use arc_eth_engine::engine::Engine; use bytesize::ByteSize; use eyre::{eyre, WrapErr}; @@ -27,7 +28,7 @@ use malachitebft_app_channel::app::types::codec::HasEncodedLen; use malachitebft_app_channel::app::types::sync::RawDecidedValue; use malachitebft_app_channel::Reply; use malachitebft_core_types::utils::height::{DisplayRange, HeightRangeExt}; -use malachitebft_core_types::Height as _; +use malachitebft_core_types::{ExtendedCommitCertificate, Height as _, VoteExtensions}; use arc_consensus_types::codec::proto::ProtobufCodec; use arc_consensus_types::sync::{Response, ValueResponse}; @@ -119,7 +120,7 @@ async fn get_decided_values( let heights = range.clone().iter_heights().collect::>(); let block_numbers = heights .iter() - .map(|height| format!("0x{:x}", height.as_u64())) + .map(|height| BlockNumberOrTag::Number(height.as_u64())) .collect::>(); let execution_payloads = engine.eth.get_execution_payloads(&block_numbers).await?; @@ -200,9 +201,13 @@ async fn get_raw_decided_value( .ok_or_else(|| eyre!("No certificate found at height {height}"))?; let decided_block = DecidedBlock::new(execution_payload, stored.certificate); + let certificate = ExtendedCommitCertificate::from_commit_certificate_and_extensions( + decided_block.certificate, + VoteExtensions::default(), + ); let raw_value = RawDecidedValue { - certificate: decided_block.certificate, + certificate, value_bytes: decided_block.execution_payload.as_ssz_bytes().into(), }; diff --git a/crates/malachite-app/src/handlers/get_history_min_height.rs b/crates/malachite-app/src/handlers/get_history_min_height.rs index 28e6e28b..d97379b7 100644 --- a/crates/malachite-app/src/handlers/get_history_min_height.rs +++ b/crates/malachite-app/src/handlers/get_history_min_height.rs @@ -14,6 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use alloy_rpc_types_eth::BlockNumberOrTag; use eyre::Context; use tracing::{debug, error, warn}; @@ -48,7 +49,11 @@ pub async fn handle(state: &State, engine: &Engine, reply: Reply) -> eyr .wrap_err("Failed to get latest height from the store")? .unwrap_or_default(); - let el_earliest_height = match engine.eth.get_block_by_number("earliest").await { + let el_earliest_height = match engine + .eth + .get_block_by_number(BlockNumberOrTag::Earliest) + .await + { Ok(Some(block)) => Height::new(block.block_number), Ok(None) => { warn!("EL returned no block for 'earliest', falling back to CL-only"); diff --git a/crates/malachite-app/src/handlers/get_value.rs b/crates/malachite-app/src/handlers/get_value.rs index 517c88c4..fa012e7a 100644 --- a/crates/malachite-app/src/handlers/get_value.rs +++ b/crates/malachite-app/src/handlers/get_value.rs @@ -17,6 +17,7 @@ use std::time::{Duration, Instant}; use eyre::{eyre, Context}; +use itertools::Itertools; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -27,33 +28,39 @@ use malachitebft_app_channel::{NetworkMsg, Reply}; use malachitebft_core_types::Validity; use arc_consensus_types::{Address, ArcContext, Height}; +use arc_eth_engine::deadline::EngineDeadline; use arc_eth_engine::engine::Engine; use arc_eth_engine::json_structures::ExecutionBlock; use arc_signer::ArcSigningProvider; use crate::block::ConsensusBlock; -use crate::metrics::AppMetrics; +use crate::metrics::{AppMetrics, BindingHaltSite}; use crate::payload::{ - generate_payload_with_retry, validate_consensus_block, EnginePayloadGenerator, - EnginePayloadValidator, + check_payload_binding, generate_payload_with_retry, validate_consensus_block, + EnginePayloadGenerator, EnginePayloadValidator, }; use crate::proposal_parts::{prepare_stream, stream_proposal}; use crate::state::State; use crate::store::repositories::UndecidedBlocksRepository; use crate::store::Store; use crate::utils::pretty::PrettyPayload; +use crate::utils::HaltAndWait; type NetworkHandle = mpsc::Sender>; /// Handles the `GetValue` message from the consensus engine. /// -/// This is called when the consensus engine requests a value to propose for a specific height and round. +/// Called when the consensus engine requests a new value to propose in a given height and round. /// -/// - The application first checks if there are any previously built blocks for the given height and round. -/// - If such blocks exist, it selects the first one to propose. -/// - If no previously built blocks are found, the application builds a new block using the execution engine, -/// validates it, and prepares it for proposal. -/// - Finally, it sends the proposed value back to the consensus engine and streams the proposal parts over the network. +/// Malachite assumes that the application is deterministic when providing proposals, namely replies +/// to the `getValue()` primitive implemented by this handler. This requires storing and re-using +/// previously produced values. +/// +/// - First, check if there is a previously built block for the given height and round. +/// - If so, to adhere to the crash-recovery model, the same block must be re-proposed. +/// - Otherwise, which should be common case, build a new block using the execution engine. +/// - Start a new stream to propagate the proposal, with the stored or new block, to all processes. +/// - Returns to the consensus engine the stored or new block's hash as the proposed value. pub async fn handle( state: &mut State, network: NetworkHandle, @@ -98,6 +105,16 @@ pub async fn handle( } } + // Feed the byzantine amnesia state machine with the value we're about + // to propose locally, so a later nil-prevote at this (height, round) + // can be overridden with `NilOrVal::Val(value_id)`. No-op when the + // byzantine feature is off or the amnesia trigger is inactive. + #[cfg(feature = "byzantine")] + if let Some(byz) = &state.ctx.byzantine { + byz.amnesia + .record_proposed_value(height, round, proposed_value.value.id()); + } + if let Err(e) = reply.send(proposed_value) { error!("🔴 GetValue: Failed to send reply: {e:?}"); } @@ -125,14 +142,17 @@ async fn on_get_value( .await .wrap_err_with(|| { format!( - "Proposer failed to get previously built blocks (if any) for height {} and round {}", + "Proposer failed to get previously built block for height {} and round {}", height, round, ) })?; let mut block = match block { Some(block) => { - info!(block_hash = %block.block_hash(), "✅ Using previously built block"); + info!(block_hash = %block.self_reported_block_hash(), "✅ Using previously built block"); + + check_reused_block_binding(&block, height, round, previous_block, &metrics)?; + block } None => { @@ -142,6 +162,13 @@ async fn on_get_value( eyre!("No previous block available to build new block at height={height} and round={round}") })?; + check_previous_block_is_predecessor(previous_block, height, &metrics)?; + + // Engine API calls below share the round's full propose budget; + // this outer timeout is the binding deadline (per-call timeouts + // never undercut it, see `EngineDeadline::call_timeout`). + let deadline = EngineDeadline::within(timeout); + let task = build_and_validate_block( engine, &metrics, @@ -151,9 +178,10 @@ async fn on_get_value( address, previous_block, &fee_recipient, + deadline, ); - let result = match tokio::time::timeout(timeout, task).await { + let result = match tokio::time::timeout_at(deadline.timeout_at(), task).await { Ok(result) => result, Err(_) => { error!(%height, %round, "⏰ Proposer timed out while building block after {timeout:?}"); @@ -176,7 +204,7 @@ async fn on_get_value( "🎁 Sending proposal: {proposed_value:?}", ); - let block_hash = block.block_hash(); + let block_hash = block.self_reported_block_hash(); let (stream_messages, signature) = prepare_stream(stream_id, signing_provider, &block) .await @@ -220,6 +248,7 @@ async fn build_and_validate_block( proposer: Address, previous_block: &ExecutionBlock, fee_recipient: &Address, + deadline: EngineDeadline, ) -> eyre::Result { let start = Instant::now(); @@ -231,26 +260,46 @@ async fn build_and_validate_block( proposer, previous_block, fee_recipient, + deadline, ) .await?; - let validator = EnginePayloadValidator::new(engine, metrics); + // The forkchoice response only confirms that the engine accepted the head we + // named. No code compares `payload.parent_hash` to that head once + // `getPayload` returns. This is that comparison. + // + // The caller established that `previous_block` sits one height below, so both + // rules apply here and a failure means the engine returned a payload built on + // something else. + check_payload_binding(&block.execution_payload, height, Some(previous_block)).wrap_err_with( + || { + format!( + "Engine returned a payload built on another block at \ + height={height}, round={round}" + ) + }, + )?; + + let validator = EnginePayloadValidator::new_with_deadline(engine, metrics, deadline); let validity = validate_consensus_block(&validator, &block, store, metrics) .await .wrap_err_with(|| { format!( "Payload validation failed on self-built block at height={height}, round={round}: {}", - block.block_hash() + block.self_reported_block_hash() ) })?; if !validity.is_valid() { - return Err(eyre!("Self-built block {} is invalid", block.block_hash())); + return Err(eyre!( + "Self-built block {} is invalid", + block.self_reported_block_hash() + )); } debug!( "✅ Proposer validated self-built block {}", - block.block_hash() + block.self_reported_block_hash() ); metrics.observe_block_build_time(start.elapsed().as_secs_f64()); @@ -258,9 +307,8 @@ async fn build_and_validate_block( Ok(block) } -/// Build a new block, validate it, and store it alongside its corresponding proposal. -/// -/// Includes timing delay enforcement to ensure proper block intervals +/// Build a new block. +#[allow(clippy::too_many_arguments)] pub async fn build_block( engine: &Engine, metrics: &AppMetrics, @@ -269,8 +317,12 @@ pub async fn build_block( proposer: Address, previous_block: &ExecutionBlock, fee_recipient: &Address, + deadline: EngineDeadline, ) -> eyre::Result { - let generator = EnginePayloadGenerator { engine }; // TODO: make this configurable + let generator = EnginePayloadGenerator { + engine, + deadline: Some(deadline), + }; // TODO: make this configurable let execution_payload = generate_payload_with_retry(previous_block, fee_recipient, &generator, metrics).await?; @@ -291,13 +343,84 @@ pub async fn build_block( }) } -/// Retrieves the previously built block for the given height and round. -/// Called by the consensus engine to re-use a previously built block. -/// Returns the first block found for the given height and round with the matching proposer. +/// Makes sure that the node's own previous block sits one height below the height +/// it is about to propose at. +/// +/// This asks about local state, not about a payload, so it needs no engine call +/// and it runs before one. A payload built on a previous block that is not the +/// predecessor comes back numbered for the wrong height, and reporting that as a +/// bad payload names the wrong cause. /// -/// There should be at most one block for a given height and round when the proposer is not byzantine. -/// We assume this implementation is not byzantine and we are the proposer for the given height and round. -/// Therefore there must be a single block for the rounds where we are the proposer, with the proposer address matching our own. +/// The mismatch is deterministic and survives a restart, so the node stops rather +/// than exiting and reading the same state again. +fn check_previous_block_is_predecessor( + previous_block: &ExecutionBlock, + height: Height, + metrics: &AppMetrics, +) -> eyre::Result<()> { + if previous_block.block_number.checked_add(1) == Some(height.as_u64()) { + return Ok(()); + } + + error!( + %height, + previous_block.number = %previous_block.block_number, + "🛑 Chain anomaly: the previous block is not the predecessor of this height; halting", + ); + + metrics.inc_binding_halt_count(BindingHaltSite::PreviousBlock); + + Err(HaltAndWait::new(format!( + "previous block {} is not the predecessor of height {height}", + previous_block.block_number + )) + .into()) +} + +/// Applies the binding rules to a stored block before this node re-proposes it. +/// +/// Re-proposing is the crash-recovery path, and the one place that streams a payload +/// the current round never checked. A restart reads the same row and breaks the same +/// rule, so the node stops with the reason rather than exiting and reading it again. +/// +/// Round start reads the same rows and answers differently: it marks an unbound one +/// invalid and continues. Rejecting a candidate there costs a nil prevote and nothing +/// else. Here the row is the block to propose, and the node cannot tell a stale row of +/// its own from one that value sync stored for the same proposer slot. +/// +/// Consensus receives no reply on this path. A `GetValue` reply carries a value, so +/// there is nothing to send when no value can be proposed. The build-timeout path +/// declines the same way, and there the round times out. Here the node stops the +/// consensus engine first, so no timeout matters. +fn check_reused_block_binding( + block: &ConsensusBlock, + height: Height, + round: Round, + previous_block: Option<&ExecutionBlock>, + metrics: &AppMetrics, +) -> eyre::Result<()> { + let Err(error) = check_payload_binding(&block.execution_payload, height, previous_block) else { + return Ok(()); + }; + + error!( + %height, %round, + "🛑 Chain anomaly: previously built block is not bound to its place in the chain; halting", + ); + + metrics.inc_binding_halt_count(BindingHaltSite::ReusedBlock); + + Err(HaltAndWait::new(format!( + "previously built block is not bound to its place in the chain at \ + height={height}, round={round}: {error}" + )) + .into()) +} + +/// Retrieves a previously built block by a proposer for the given height and round, if any. +/// +/// There should be at most one block for a given height, round, and proposer. +/// Produces an error if multiple matching blocks are found in the undecided blocks database. async fn get_previously_built_block( undecided_blocks: impl UndecidedBlocksRepository, proposer: Address, @@ -305,6 +428,322 @@ async fn get_previously_built_block( round: Round, ) -> eyre::Result> { let blocks = undecided_blocks.get_by_round(height, round).await?; - let block = blocks.into_iter().find(|p| p.proposer == proposer); + let block = blocks + .into_iter() + .filter(|p| p.proposer == proposer) + .at_most_one() + .map_err(|dups| { + let hashes: Vec<_> = dups.map(|b| b.self_reported_block_hash()).collect(); + eyre!("Multiple undecided blocks found for proposer {proposer} at height {height} and round {round}: {hashes:?}") + })?; + Ok(block) } + +#[cfg(test)] +mod tests { + use super::*; + + use mockall::predicate::*; + + use alloy_primitives::{Address as AlloyAddress, Bloom, Bytes as AlloyBytes, U256}; + use alloy_rpc_types_engine::{ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3}; + use arc_consensus_types::{signing::Signature, B256}; + use malachitebft_core_types::Validity; + + use crate::store::repositories::mocks::MockUndecidedBlocksRepository; + use crate::utils::HaltAndWait; + + fn test_execution_payload(block_hash_byte: u8) -> ExecutionPayloadV3 { + ExecutionPayloadV3 { + payload_inner: ExecutionPayloadV2 { + payload_inner: ExecutionPayloadV1 { + parent_hash: B256::ZERO, + fee_recipient: AlloyAddress::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::default(), + prev_randao: B256::ZERO, + block_number: 1, + gas_limit: 30000000, + gas_used: 0, + timestamp: 1000, + extra_data: AlloyBytes::default(), + base_fee_per_gas: U256::from(1u64), + block_hash: B256::repeat_byte(block_hash_byte), + transactions: vec![], + }, + withdrawals: vec![], + }, + blob_gas_used: 0, + excess_blob_gas: 0, + } + } + + fn test_block( + height: Height, + round: Round, + proposer: Address, + block_hash_byte: u8, + ) -> ConsensusBlock { + ConsensusBlock { + height, + round, + valid_round: Round::Nil, + proposer, + validity: Validity::Valid, + execution_payload: test_execution_payload(block_hash_byte), + signature: Some(Signature::test()), + } + } + + #[test] + fn previous_block_one_height_below_is_the_predecessor() { + let previous_block = ExecutionBlock { + block_hash: B256::ZERO, + block_number: 10, + parent_hash: B256::ZERO, + timestamp: 0, + }; + + check_previous_block_is_predecessor( + &previous_block, + Height::new(11), + &AppMetrics::default(), + ) + .expect("block 10 is the predecessor of height 11"); + } + + /// Stale local state names itself, rather than being reported as a bad + /// payload once the engine builds on it. + #[test] + fn previous_block_at_another_height_fail_stops() { + let previous_block = ExecutionBlock { + block_hash: B256::ZERO, + block_number: 7, + parent_hash: B256::ZERO, + timestamp: 0, + }; + + let metrics = AppMetrics::default(); + let error = check_previous_block_is_predecessor(&previous_block, Height::new(11), &metrics) + .expect_err("block 7 is not the predecessor of height 11"); + + assert!( + error.downcast_ref::().is_some(), + "expected a fail-stop, got: {error:#}", + ); + assert!( + format!("{error:#}").contains("is not the predecessor of height 11"), + "the halt must name the local state, got: {error:#}", + ); + assert_eq!( + metrics.get_binding_halt_count(BindingHaltSite::PreviousBlock), + 1, + ); + } + + #[test] + fn reused_block_at_its_height_is_accepted() { + let height = Height::new(1); + let round = Round::new(2); + let block = test_block(height, round, Address::new([1u8; 20]), 0xAA); + + // The payload reports block number 1 and extends the zero hash. + let previous_block = ExecutionBlock { + block_hash: B256::ZERO, + block_number: 0, + parent_hash: B256::ZERO, + timestamp: 0, + }; + + check_reused_block_binding( + &block, + height, + round, + Some(&previous_block), + &AppMetrics::default(), + ) + .expect("a reused block at its height is bound"); + } + + /// A stored row that breaks the binding rules stops the node, rather than being + /// streamed to the network or exiting the process. + #[test] + fn reused_block_from_another_height_fail_stops() { + let height = Height::new(7); + let round = Round::new(0); + // The payload reports block number 1, so it does not belong at height 7. + let block = test_block(height, round, Address::new([1u8; 20]), 0xAA); + + let metrics = AppMetrics::default(); + let error = check_reused_block_binding(&block, height, round, None, &metrics) + .expect_err("a reused block from another height must not be proposed"); + + assert!( + error.downcast_ref::().is_some(), + "expected a fail-stop, got: {error:#}", + ); + assert!( + format!("{error:#}").contains("does not match consensus height"), + "the halt must name the broken rule, got: {error:#}", + ); + assert_eq!( + metrics.get_binding_halt_count(BindingHaltSite::ReusedBlock), + 1, + ); + } + + /// The parent rule reaches this arm too. A stored row can carry the right block + /// number and still extend a block that is not the one finalized below it. + #[test] + fn reused_block_that_extends_another_block_fail_stops() { + let height = Height::new(1); + let round = Round::new(0); + // The payload reports block number 1 and extends the zero hash. + let block = test_block(height, round, Address::new([1u8; 20]), 0xAA); + + // The immediate predecessor, and not the block the payload extends. + let previous_block = ExecutionBlock { + block_hash: B256::repeat_byte(0xAB), + block_number: 0, + parent_hash: B256::ZERO, + timestamp: 0, + }; + + let metrics = AppMetrics::default(); + let error = + check_reused_block_binding(&block, height, round, Some(&previous_block), &metrics) + .expect_err("a reused block that extends another block must not be proposed"); + + assert!( + error.downcast_ref::().is_some(), + "expected a fail-stop, got: {error:#}", + ); + assert!( + format!("{error:#}").contains("is not the block finalized at the previous height"), + "the halt must name the broken rule, got: {error:#}", + ); + assert_eq!( + metrics.get_binding_halt_count(BindingHaltSite::ReusedBlock), + 1, + ); + } + + #[tokio::test] + async fn returns_none_when_no_blocks_stored() { + let height = Height::new(1); + let round = Round::new(0); + + let mut mock = MockUndecidedBlocksRepository::new(); + mock.expect_get_by_round() + .with(eq(height), eq(round)) + .return_once(|_, _| Ok(vec![])); + + let result = get_previously_built_block(mock, Address::new([1u8; 20]), height, round).await; + + assert!(result.unwrap().is_none()); + } + + #[tokio::test] + async fn returns_block_when_single_match() { + let height = Height::new(5); + let round = Round::new(2); + let proposer = Address::new([1u8; 20]); + let block = test_block(height, round, proposer, 0xAA); + let expected_hash = block.self_reported_block_hash(); + + let mut mock = MockUndecidedBlocksRepository::new(); + mock.expect_get_by_round() + .with(eq(height), eq(round)) + .return_once(move |_, _| Ok(vec![block])); + + let result = get_previously_built_block(mock, proposer, height, round).await; + + let found = result.unwrap().unwrap(); + assert_eq!(found.self_reported_block_hash(), expected_hash); + assert_eq!(found.proposer, proposer); + } + + #[tokio::test] + async fn returns_none_when_proposer_does_not_match() { + let height = Height::new(5); + let round = Round::new(2); + let stored_proposer = Address::new([1u8; 20]); + let queried_proposer = Address::new([2u8; 20]); + let block = test_block(height, round, stored_proposer, 0xAA); + + let mut mock = MockUndecidedBlocksRepository::new(); + mock.expect_get_by_round() + .with(eq(height), eq(round)) + .return_once(move |_, _| Ok(vec![block])); + + let result = get_previously_built_block(mock, queried_proposer, height, round).await; + + assert!(result.unwrap().is_none()); + } + + #[tokio::test] + async fn returns_matching_block_among_different_proposers() { + let height = Height::new(5); + let round = Round::new(2); + let proposer_a = Address::new([1u8; 20]); + let proposer_b = Address::new([2u8; 20]); + let block_a = test_block(height, round, proposer_a, 0xAA); + let block_b = test_block(height, round, proposer_b, 0xBB); + let expected_hash = block_a.self_reported_block_hash(); + + let mut mock = MockUndecidedBlocksRepository::new(); + mock.expect_get_by_round() + .with(eq(height), eq(round)) + .return_once(move |_, _| Ok(vec![block_a, block_b])); + + let result = get_previously_built_block(mock, proposer_a, height, round).await; + + let found = result.unwrap().unwrap(); + assert_eq!(found.self_reported_block_hash(), expected_hash); + assert_eq!(found.proposer, proposer_a); + } + + #[tokio::test] + async fn errors_when_multiple_blocks_for_same_proposer() { + let height = Height::new(5); + let round = Round::new(2); + let proposer = Address::new([1u8; 20]); + let block_1 = test_block(height, round, proposer, 0xAA); + let block_2 = test_block(height, round, proposer, 0xBB); + + let mut mock = MockUndecidedBlocksRepository::new(); + mock.expect_get_by_round() + .with(eq(height), eq(round)) + .return_once(move |_, _| Ok(vec![block_1, block_2])); + + let result = get_previously_built_block(mock, proposer, height, round).await; + + let err = result.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Multiple undecided blocks found"), + "Expected 'Multiple undecided blocks found' in error, got: {msg}" + ); + } + + #[tokio::test] + async fn propagates_repository_error() { + let height = Height::new(5); + let round = Round::new(2); + + let mut mock = MockUndecidedBlocksRepository::new(); + mock.expect_get_by_round() + .with(eq(height), eq(round)) + .return_once(|_, _| Err(std::io::Error::other("db connection lost"))); + + let result = get_previously_built_block(mock, Address::new([1u8; 20]), height, round).await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("db connection lost")); + } +} diff --git a/crates/malachite-app/src/handlers/mod.rs b/crates/malachite-app/src/handlers/mod.rs index 2b2215a9..5c27d00c 100644 --- a/crates/malachite-app/src/handlers/mod.rs +++ b/crates/malachite-app/src/handlers/mod.rs @@ -28,6 +28,7 @@ pub mod get_value; pub mod process_synced_value; pub mod received_proposal_part; pub mod restream_proposal; +mod skew_gate; pub mod started_round; #[cfg(test)] diff --git a/crates/malachite-app/src/handlers/process_synced_value.rs b/crates/malachite-app/src/handlers/process_synced_value.rs index f56125dc..c5331bf7 100644 --- a/crates/malachite-app/src/handlers/process_synced_value.rs +++ b/crates/malachite-app/src/handlers/process_synced_value.rs @@ -21,6 +21,7 @@ use eyre::Context; use ssz::Decode; use tracing::{error, warn}; +use malachitebft_app_channel::app::engine::host::SyncedValueOutcome; use malachitebft_app_channel::app::types::core::Round; use malachitebft_app_channel::app::types::ProposedValue; use malachitebft_app_channel::Reply; @@ -28,13 +29,17 @@ use malachitebft_app_channel::Reply; use alloy_rpc_types_engine::ExecutionPayloadV3; use arc_consensus_types::{Address, ArcContext, Height}; use arc_eth_engine::engine::Engine; +use arc_eth_engine::json_structures::ExecutionBlock; use arc_eth_engine::persistence_meter::PersistenceMeter; use malachitebft_app_channel::app::types::core::Validity; use crate::block::ConsensusBlock; use crate::metrics::{AppMetrics, InvalidPayloadSource}; -use crate::payload::{validate_consensus_block, EnginePayloadValidator, PayloadValidator}; +use crate::payload::{ + establish_block_validity, persist_invalid_payload_best_effort, BlockVerdict, + EnginePayloadValidator, PayloadValidator, +}; use crate::state::State; use crate::store::repositories::{InvalidPayloadsRepository, UndecidedBlocksRepository}; use arc_consensus_db::invalid_payloads::InvalidPayload; @@ -46,10 +51,14 @@ const SYNC_PERSISTENCE_WAIT_TIMEOUT: Duration = Duration::from_secs(30); /// /// This is called when the consensus engine has received a value via sync for a given height and round. /// The application processes the synced value, validates it, and stores it for future use. -/// If the value is valid, it is returned as a `ProposedValue` to the consensus engine. -/// If the value is invalid, `None` is returned. -/// In both cases, the block is stored in the undecided blocks store for use once consensus reaches -/// that height. +/// The reply tells the sync layer how the value was handled: +/// - [`SyncedValueOutcome::Verdict`] — a validated `ProposedValue` (validity rides inside) to forward +/// to consensus; the block is stored in the undecided blocks store for use once consensus reaches +/// that height. +/// - [`SyncedValueOutcome::PeerFault`] — the value is one no correct peer serves: undecodable +/// bytes, or a self-reported hash that is not canonical. Penalize and re-request. +/// - [`SyncedValueOutcome::LocalTransientError`] — a local/transient failure on our side (e.g. the EL +/// being temporarily unavailable); the peer is innocent, so re-request without penalizing it. pub async fn handle( state: &mut State, engine: &Engine, @@ -57,9 +66,9 @@ pub async fn handle( round: Round, proposer: Address, value_bytes: Bytes, - reply: Reply>>, + reply: Reply>, ) -> Result<(), eyre::Error> { - let proposal = match on_process_synced_value( + let outcome = match on_process_synced_value( EnginePayloadValidator::new(engine, state.metrics()), state.store(), state.store(), @@ -69,27 +78,25 @@ pub async fn handle( round, proposer, value_bytes, + state.previous_block, ) .await { - Ok(proposal) => proposal, + Ok(Some(proposal)) => { + // Mark this height as synced for proposal monitoring. + if proposal.validity.is_valid() { + state.mark_height_synced(height); + } + SyncedValueOutcome::Verdict(proposal) + } + Ok(None) => SyncedValueOutcome::PeerFault, Err(e) => { error!(%height, %round, %proposer, "ProcessSyncedValue failed: {e:#}"); - if let Err(send_err) = reply.send(None) { - error!("🔴 ProcessSyncedValue: Failed to send error reply: {send_err:?}"); - } - return Err(e); + SyncedValueOutcome::LocalTransientError } }; - // Mark this height as synced for proposal monitoring - if let Some(p) = &proposal - && p.validity.is_valid() - { - state.mark_height_synced(height); - } - - if let Err(e) = reply.send(proposal) { + if let Err(e) = reply.send(outcome) { error!("🔴 ProcessSyncedValue: Failed to send reply: {e:?}"); } @@ -99,14 +106,18 @@ pub async fn handle( /// Processes a synced value received from a peer. /// /// Decodes the raw bytes into an [`ExecutionPayloadV3`], validates it via -/// [`validate_consensus_block`], and stores the resulting [`ConsensusBlock`] as an +/// [`establish_block_validity`], and stores the resulting [`ConsensusBlock`] as an /// undecided block. If the engine rejects the payload, an /// [`InvalidPayload`](crate::invalid_payloads::InvalidPayload) record is persisted /// through `store` and the block is kept with [`Validity::Invalid`] so that /// consensus can proceed with the correct validity information. /// -/// Returns `Ok(None)` when the raw bytes cannot be SSZ-decoded (the error is logged -/// but not propagated). +/// Returns `Ok(None)` for bytes that do not SSZ-decode, and for a self-reported hash +/// that is not canonical. Consensus reads that reply as a peer fault, penalizes the +/// peer and requests the height from another one. +/// +/// A payload that breaks a binding rule returns the `Invalid` verdict instead, so the +/// value-id check after this reply can tell a bad peer from a real anomaly. #[allow(clippy::too_many_arguments)] async fn on_process_synced_value( engine: impl PayloadValidator, @@ -118,6 +129,7 @@ async fn on_process_synced_value( round: Round, proposer: Address, value_bytes: Bytes, + previous_block: Option, ) -> eyre::Result>> { let payload = match ExecutionPayloadV3::from_ssz_bytes(&value_bytes) { Ok(payload) => payload, @@ -131,19 +143,22 @@ async fn on_process_synced_value( let invalid = InvalidPayload::new_without_payload(height, round, proposer, &format!("{e:?}")); - invalid_payloads_repo.append(invalid).await.wrap_err_with(|| { - format!( - "Failed to store invalid payload after receiving synced value (height={height}, round={round}, proposer={proposer})", - ) - })?; + persist_invalid_payload_best_effort( + &invalid_payloads_repo, + invalid, + height, + round, + proposer, + ) + .await; return Ok(None); } }; // Build the block before validation so that - // `validate_consensus_block` can record an `InvalidPayload` - // with the full block context if the engine rejects it. + // `establish_block_validity` can record an `InvalidPayload` + // with the full block context if a rule or the engine rejects it. let mut block = ConsensusBlock { height, round, @@ -154,8 +169,12 @@ async fn on_process_synced_value( signature: None, }; - let validity = validate_consensus_block( - &engine, &block, &invalid_payloads_repo, metrics, + let verdict = establish_block_validity( + &engine, + &block, + previous_block.as_ref(), + &invalid_payloads_repo, + metrics, ) .await .wrap_err_with(|| { @@ -165,26 +184,52 @@ async fn on_process_synced_value( ) })?; + let validity = verdict.validity(); block.validity = validity; - let block_hash = block.block_hash(); + let block_hash = block.self_reported_block_hash(); if !validity.is_valid() { error!(%height, %round, %proposer, %block_hash, "❌ Received invalid payload via sync"); } + // Don't key a non-canonical block into the undecided table; its invalid + // record was already persisted by `establish_block_validity`. + if !block.may_be_stored_as_undecided() { + warn!( + %height, %round, %proposer, %block_hash, + "Synced value self-reported hash is not canonical; not storing as undecided", + ); + return Ok(None); + } + + // An unbound payload keeps the `Invalid` verdict and goes to consensus, which + // compares the value id to the certificate after this reply. That comparison + // separates the two causes this code cannot: bytes no certificate covers, and a + // payload the network really committed. A peer fault here would be right for the + // first only, and would blame honest peers for a state this node holds. + // + // It skips dedup on the way: a row already stored `Valid` would otherwise answer + // for the fresh `Invalid` verdict and carry the payload forward. + let is_unbound = matches!(verdict, BlockVerdict::Unbound(_)); + // If a undecided block for the sync value height round and hash exists then skip `wait_for_persisted_block` // so consensus path is not blocked on EL persistence. - if let Some(existing) = undecided_blocks_repo - .get_by_round_and_hash(height, round, block_hash) - .await - .wrap_err_with(|| { - format!( - "Failed to query undecided blocks repo for dedup at \ - height={height}, round={round}, block_hash={block_hash}" - ) - })? - { + let existing = if is_unbound { + None + } else { + undecided_blocks_repo + .get_by_round_and_hash(height, round, block_hash) + .await + .wrap_err_with(|| { + format!( + "Failed to query undecided blocks repo for dedup at \ + height={height}, round={round}, block_hash={block_hash}" + ) + })? + }; + + if let Some(existing) = existing { debug_assert_eq!( existing.validity, validity, "dedup hit at height={height}, round={round}, block_hash={block_hash}: \ @@ -247,6 +292,252 @@ mod tests { .returning(|_, _, _| Ok(None)); } + /// Builds an arbitrary payload that carries `height` as its block number. + fn payload_at(u: &mut Unstructured, height: Height) -> ExecutionPayloadV3 { + let mut payload = ExecutionPayloadV3::arbitrary(u).unwrap(); + payload.payload_inner.payload_inner.block_number = height.as_u64(); + payload + } + + /// Builds a payload for `height` whose embedded `block_hash` equals the hash + /// recomputed from its contents. + fn canonical_payload(u: &mut Unstructured, height: Height) -> ExecutionPayloadV3 { + let mut payload = payload_at(u, height); + let canonical = arc_consensus_types::block::canonical_block_hash(&payload) + .expect("recompute canonical hash"); + payload.payload_inner.payload_inner.block_hash = canonical; + payload + } + + /// A genuine block from height 5, synced as the value for height 11. The + /// verdict is `Invalid` and the engine never sees the payload. + #[tokio::test] + async fn on_process_synced_value_rejects_a_payload_from_another_height() { + let mut u = Unstructured::new(&[0u8; 512]); + + let height = Height::new(11); + let round = Round::new(0); + let proposer = Address::new([0u8; 20]); + let payload = canonical_payload(&mut u, Height::new(5)); + let value_bytes = Bytes::from(payload.as_ssz_bytes()); + + let mut engine = MockPayloadValidator::new(); + engine.expect_validate_payload().times(0); + + let mut undecided = MockUndecidedBlocksRepository::new(); + expect_no_undecided_dedup_hit(&mut undecided); + undecided + .expect_store_undecided_block() + .times(1) + .returning(|_| Ok(())); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid + .expect_append() + .times(1) + .withf(|ip: &InvalidPayload| ip.reason.contains("does not match consensus height")) + .returning(|_| Ok(())); + + let metrics = AppMetrics::default(); + let outcome = on_process_synced_value( + engine, + undecided, + invalid, + NoopPersistenceMeter, + &metrics, + height, + round, + proposer, + value_bytes, + None, + ) + .await + .expect("an unbound synced value is a verdict, not a failure"); + + let proposal = outcome.expect("the verdict must reach consensus"); + assert_eq!(proposal.validity, Validity::Invalid); + assert_eq!( + metrics.get_invalid_payloads_count_by_source(InvalidPayloadSource::PayloadHeight), + 1, + ); + } + + /// A payload at the right height that extends some other block. This is the + /// parent rule reaching an ingestion path, and the reason it records. + #[tokio::test] + async fn on_process_synced_value_rejects_a_payload_that_extends_another_block() { + let mut u = Unstructured::new(&[0u8; 512]); + + let height = Height::new(11); + let round = Round::new(0); + let proposer = Address::new([0u8; 20]); + let payload = canonical_payload(&mut u, height); + let value_bytes = Bytes::from(payload.as_ssz_bytes()); + + // The immediate predecessor, and not the block the payload extends. + let previous_block = ExecutionBlock { + block_hash: arc_consensus_types::B256::repeat_byte(0xAB), + block_number: 10, + parent_hash: arc_consensus_types::B256::ZERO, + timestamp: 0, + }; + + let mut engine = MockPayloadValidator::new(); + engine.expect_validate_payload().times(0); + + let mut undecided = MockUndecidedBlocksRepository::new(); + expect_no_undecided_dedup_hit(&mut undecided); + undecided + .expect_store_undecided_block() + .times(1) + .returning(|_| Ok(())); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid + .expect_append() + .times(1) + .withf(|ip: &InvalidPayload| { + ip.reason + .contains("is not the block finalized at the previous height") + }) + .returning(|_| Ok(())); + + let metrics = AppMetrics::default(); + let outcome = on_process_synced_value( + engine, + undecided, + invalid, + NoopPersistenceMeter, + &metrics, + height, + round, + proposer, + value_bytes, + Some(previous_block), + ) + .await + .expect("an unbound synced value is a verdict, not a failure"); + + let proposal = outcome.expect("the verdict must reach consensus"); + assert_eq!(proposal.validity, Validity::Invalid); + } + + /// A row stored `Valid` while `previous_block` lagged, re-evaluated once the + /// parent rule applies. Dedup must not answer for the fresh verdict: the stored + /// row would carry the payload forward as `Valid`. + #[tokio::test] + async fn on_process_synced_value_does_not_dedup_against_a_stored_row_after_a_binding_error() { + let mut u = Unstructured::new(&[0u8; 512]); + + let height = Height::new(11); + let round = Round::new(0); + let proposer = Address::new([0u8; 20]); + let payload = canonical_payload(&mut u, height); + let value_bytes = Bytes::from(payload.as_ssz_bytes()); + + let previous_block = ExecutionBlock { + block_hash: arc_consensus_types::B256::repeat_byte(0xAB), + block_number: 10, + parent_hash: arc_consensus_types::B256::ZERO, + timestamp: 0, + }; + + let mut engine = MockPayloadValidator::new(); + engine.expect_validate_payload().times(0); + + let mut undecided = MockUndecidedBlocksRepository::new(); + // Dedup reads the stored row. A binding failure never asks for it, so a row + // already stored `Valid` cannot answer in place of the fresh verdict. + undecided.expect_get_by_round_and_hash().times(0); + undecided + .expect_store_undecided_block() + .times(1) + .withf(|b: &ConsensusBlock| b.validity == Validity::Invalid) + .returning(|_| Ok(())); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid.expect_append().times(1).returning(|_| Ok(())); + + let metrics = AppMetrics::default(); + let outcome = on_process_synced_value( + engine, + undecided, + invalid, + NoopPersistenceMeter, + &metrics, + height, + round, + proposer, + value_bytes, + Some(previous_block), + ) + .await + .expect("an unbound synced value is a verdict, not a failure"); + + let proposal = outcome.expect("the verdict must reach consensus"); + assert_eq!(proposal.validity, Validity::Invalid); + assert_eq!( + metrics.get_invalid_payloads_count_by_source(InvalidPayloadSource::PayloadParent), + 1, + ); + } + + /// Batch value sync delivers heights ahead of the node's previous block. That + /// block is not the parent of the payload, so only the engine decides here. + #[tokio::test] + async fn on_process_synced_value_skips_the_parent_rule_for_a_height_ahead() { + let mut u = Unstructured::new(&[0u8; 512]); + + let height = Height::new(11); + let round = Round::new(0); + let proposer = Address::new([0u8; 20]); + let payload = canonical_payload(&mut u, height); + let value_bytes = Bytes::from(payload.as_ssz_bytes()); + + let previous_block = ExecutionBlock { + block_hash: arc_consensus_types::B256::repeat_byte(0xAB), + block_number: 7, + parent_hash: arc_consensus_types::B256::ZERO, + timestamp: 0, + }; + + let mut engine = MockPayloadValidator::new(); + engine + .expect_validate_payload() + .times(1) + .returning(|_| Ok(PayloadValidationResult::Valid)); + + let mut undecided = MockUndecidedBlocksRepository::new(); + expect_no_undecided_dedup_hit(&mut undecided); + undecided + .expect_store_undecided_block() + .times(1) + .returning(|_| Ok(())); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid.expect_append().times(0); + + let metrics = AppMetrics::default(); + let proposal = on_process_synced_value( + engine, + undecided, + invalid, + NoopPersistenceMeter, + &metrics, + height, + round, + proposer, + value_bytes, + Some(previous_block), + ) + .await + .expect("should succeed") + .expect("the verdict reaches consensus"); + + assert_eq!(proposal.validity, Validity::Valid); + assert_eq!(metrics.get_invalid_payloads_count(), 0); + } + async fn test_on_process_synced_value_validity( result: PayloadValidationResult, expected: Validity, @@ -256,7 +547,7 @@ mod tests { let height = Height::new(1); let round = Round::new(0); let proposer = Address::new([0u8; 20]); - let payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + let payload = canonical_payload(&mut u, height); let value_bytes = Bytes::from(payload.as_ssz_bytes()); let mut engine = MockPayloadValidator::new(); @@ -293,6 +584,7 @@ mod tests { round, proposer, value_bytes, + None, ) .await .expect("Failed to process synced value") else { @@ -321,6 +613,122 @@ mod tests { .await; } + /// The value-sync path applies no clock-skew check: a value whose timestamp is + /// far ahead of local time still validates execution-`Valid` and is stored + /// `Valid`, so a clock-lagging node adopts a certificate-backed block with no + /// restart. The skew guard lives only on the live prevote path. + #[tokio::test] + async fn on_process_synced_value_ignores_clock_skew_for_future_timestamp() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let mut u = Unstructured::new(&[0u8; 512]); + + let height = Height::new(9); + let round = Round::new(0); + let proposer = Address::new([0u8; 20]); + + // Stamp the payload well beyond any clock-skew tolerance ahead of now. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut payload = payload_at(&mut u, height); + payload.payload_inner.payload_inner.timestamp = now + 3600; + payload.payload_inner.payload_inner.block_hash = + arc_consensus_types::block::canonical_block_hash(&payload) + .expect("recompute canonical hash"); + let value_bytes = Bytes::from(payload.as_ssz_bytes()); + + let mut engine = MockPayloadValidator::new(); + engine + .expect_validate_payload() + .times(1) + .returning(|_| Ok(PayloadValidationResult::Valid)); + + let mut undecided = MockUndecidedBlocksRepository::new(); + expect_no_undecided_dedup_hit(&mut undecided); + undecided + .expect_store_undecided_block() + .withf(|b: &ConsensusBlock| b.validity == Validity::Valid) + .times(1) + .returning(|_| Ok(())); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid.expect_append().times(0); + + let metrics = AppMetrics::default(); + let proposal = on_process_synced_value( + engine, + undecided, + invalid, + NoopPersistenceMeter, + &metrics, + height, + round, + proposer, + value_bytes, + None, + ) + .await + .expect("sync path must not fail on a future-timestamp value") + .expect("the verdict reaches consensus"); + + assert_eq!( + proposal.validity, + Validity::Valid, + "the value-sync path ignores clock skew and adopts the value", + ); + assert_eq!(metrics.get_invalid_payloads_count(), 0); + } + + /// A synced value whose self-reported hash does not match its payload + /// contents is recorded as invalid and never stored as an undecided block, + /// so it cannot overwrite a valid block advertised under the same hash. + #[tokio::test] + async fn on_process_synced_value_non_canonical_hash_is_not_stored() { + let mut u = Unstructured::new(&[0u8; 512]); + + let height = Height::new(1); + let round = Round::new(0); + let proposer = Address::new([0u8; 20]); + + let mut payload = canonical_payload(&mut u, height); + payload.payload_inner.payload_inner.block_hash = + arc_consensus_types::B256::repeat_byte(0xAB); + let value_bytes = Bytes::from(payload.as_ssz_bytes()); + + let mut engine = MockPayloadValidator::new(); + engine.expect_validate_payload().returning(|_| { + Ok(PayloadValidationResult::Invalid { + reason: "block hash mismatch".into(), + }) + }); + + let mut undecided = MockUndecidedBlocksRepository::new(); + undecided.expect_store_undecided_block().times(0); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid.expect_append().times(1).returning(|_| Ok(())); + + let metrics = AppMetrics::default(); + let result = on_process_synced_value( + engine, + undecided, + invalid, + NoopPersistenceMeter, + &metrics, + height, + round, + proposer, + value_bytes, + None, + ) + .await + .expect("Failed to process synced value"); + + assert!(result.is_none()); + } + #[tokio::test] async fn test_on_process_synced_value_invalid_bytes() { let mut engine = MockPayloadValidator::new(); @@ -349,6 +757,7 @@ mod tests { round, proposer, value_bytes, + None, ) .await .expect("Failed to process synced value"); @@ -368,7 +777,7 @@ mod tests { let height = Height::new(1); let round = Round::new(0); let proposer = Address::new([0u8; 20]); - let payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + let payload = payload_at(&mut u, height); let value_bytes = Bytes::from(payload.as_ssz_bytes()); let mut engine = MockPayloadValidator::new(); @@ -393,6 +802,7 @@ mod tests { round, proposer, value_bytes, + None, ) .await; @@ -430,10 +840,15 @@ mod tests { round, proposer, value_bytes, + None, ) - .await; + .await + .expect("forensics persistence failure must not propagate"); - assert!(result.is_err()); + assert!( + result.is_none(), + "an undecodable synced value yields no proposal even when the forensic record fails to persist", + ); assert_eq!(metrics.get_invalid_payloads_count(), 1); } @@ -444,7 +859,7 @@ mod tests { let height = Height::new(1); let round = Round::new(0); let proposer = Address::new([0u8; 20]); - let payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + let payload = payload_at(&mut u, height); let value_bytes = Bytes::from(payload.as_ssz_bytes()); let mut engine = MockPayloadValidator::new(); @@ -473,6 +888,7 @@ mod tests { round, proposer, value_bytes, + None, ) .await; @@ -488,7 +904,7 @@ mod tests { let height = Height::new(42); let round = Round::new(0); let proposer = Address::new([0u8; 20]); - let payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + let payload = payload_at(&mut u, height); let value_bytes = Bytes::from(payload.as_ssz_bytes()); let mut engine = MockPayloadValidator::new(); @@ -524,6 +940,7 @@ mod tests { round, proposer, value_bytes, + None, ) .await .expect("should succeed"); @@ -540,7 +957,7 @@ mod tests { let height = Height::new(42); let round = Round::new(0); let proposer = Address::new([0u8; 20]); - let payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + let payload = canonical_payload(&mut u, height); let value_bytes = Bytes::from(payload.as_ssz_bytes()); let mut engine = MockPayloadValidator::new(); @@ -574,6 +991,7 @@ mod tests { round, proposer, value_bytes, + None, ) .await .expect("should succeed"); @@ -590,7 +1008,7 @@ mod tests { let height = Height::new(7); let round = Round::new(0); let proposer = Address::new([0u8; 20]); - let payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + let payload = payload_at(&mut u, height); let value_bytes = Bytes::from(payload.as_ssz_bytes()); let mut engine = MockPayloadValidator::new(); @@ -626,6 +1044,7 @@ mod tests { round, proposer, value_bytes, + None, ) .await .expect("should succeed even when meter fails"); @@ -653,7 +1072,7 @@ mod tests { let height = Height::new(42); let round = Round::new(0); let proposer = Address::new([1u8; 20]); - let payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + let payload = payload_at(&mut u, height); let block_hash = payload.payload_inner.payload_inner.block_hash; let value_bytes = Bytes::from(payload.as_ssz_bytes()); @@ -702,6 +1121,7 @@ mod tests { round, proposer, value_bytes, + None, ) .await .expect("should succeed via dedup short-circuit"); diff --git a/crates/malachite-app/src/handlers/received_proposal_part.rs b/crates/malachite-app/src/handlers/received_proposal_part.rs index d68cb9bf..2fae9be4 100644 --- a/crates/malachite-app/src/handlers/received_proposal_part.rs +++ b/crates/malachite-app/src/handlers/received_proposal_part.rs @@ -23,14 +23,19 @@ use malachitebft_app_channel::app::types::{PeerId, ProposedValue}; use malachitebft_app_channel::Reply; use malachitebft_core_types::Height as _; +use arc_consensus_types::proposal_monitor::ProposalMonitor; use arc_consensus_types::proposer::ProposerSelector; use arc_consensus_types::{ArcContext, Height, ProposalPart, ProposalParts, Round, ValidatorSet}; use arc_eth_engine::engine::Engine; +use arc_eth_engine::json_structures::ExecutionBlock; use arc_signer::ArcSigningProvider; +use super::skew_gate; use crate::block::ConsensusBlock; -use crate::metrics::{AppMetrics, InvalidPayloadSource}; -use crate::payload::{validate_consensus_block, EnginePayloadValidator}; +use crate::metrics::{AppMetrics, InvalidPayloadSource, SkewNilVoteSource}; +use crate::payload::{ + establish_block_validity, persist_invalid_payload_best_effort, EnginePayloadValidator, +}; use crate::proposal_parts::{ assemble_block_from_parts, resolve_expected_proposer, validate_proposal_parts, }; @@ -60,6 +65,7 @@ pub async fn handle( let current_round = state.current_round; let current_validator_set = state.validator_set().clone(); let proposer_selector = state.ctx.proposer_selector; + let previous_block = state.previous_block; let context = HandlerContext { engine, @@ -72,33 +78,89 @@ pub async fn handle( current_validator_set, proposer_selector: &proposer_selector, max_pending_proposals, + previous_block, }; - let response = on_received_proposal_part(context, from, part) + let outcome = on_received_proposal_part(context, from, part) .await .inspect_err(|e| { error!(%from, "🔴 Error processing proposal part: {e:#}"); }) - .unwrap_or(None); + .unwrap_or(ReceivedPart::None); - if let Some(proposed_value) = &response { - record_proposal_in_monitor(state, proposed_value); - } + let response = match outcome { + ReceivedPart::Assembled(proposed_value) => { + let current_round = state.current_round; + record_proposal_in_monitor(&mut state.proposal_monitor, current_round, &proposed_value); + + // Feed the byzantine amnesia state machine with incoming proposals, + // so a later nil-prevote at this (height, round) can be overridden + // with `NilOrVal::Val(value_id)`. No-op when the byzantine feature + // is off or the amnesia trigger is inactive. + #[cfg(feature = "byzantine")] + if let Some(byz) = &state.ctx.byzantine { + byz.amnesia.record_proposed_value( + proposed_value.height, + proposed_value.round, + proposed_value.value.id(), + ); + } + + Some(proposed_value) + } + ReceivedPart::BufferedPending(height) => { + // Parts for a future height completed before this node entered it. + // Record the receive time against that height so its monitor — + // created later, at round-0 start — shows the early arrival + // (negative `proposal_delay_ms`). Value id is attached when they reassemble. + state.record_early_pending_parts_time(height); + None + } + ReceivedPart::None => None, + }; if let Err(e) = reply.send(response) { error!("🔴 ReceivedProposalPart: Failed to send reply: {e:?}"); } } -/// Records the proposal receipt in the proposal monitor. -fn record_proposal_in_monitor(state: &mut State, proposed_value: &ProposedValue) { - let current_round = state.current_round; +/// What handling a received proposal part produced, from the perspective of +/// the proposal monitor and the consensus reply. +enum ReceivedPart { + /// A complete current-height proposal was assembled, validated, and stored. + Assembled(ProposedValue), + /// A complete proposal for a future height was buffered as pending. + BufferedPending(Height), + /// Nothing actionable for the monitor or the consensus reply. + None, +} + +/// Records a live-received round-0 proposal in the proposal monitor. +/// +/// Fast path: parts arrived while the node was already at the proposal's +/// height, so the monitor exists and we record directly. The pending-parts +/// case (parts arrive before the node reaches the height) does not flow +/// through here. +fn record_proposal_in_monitor( + monitor: &mut Option, + current_round: Round, + proposed_value: &ProposedValue, +) { + if !proposed_value.validity.is_valid() { + warn!( + %proposed_value.height, + %proposed_value.proposer, + "Not recording invalid proposal in monitor", + ); + return; + } + if current_round.as_i64() != 0 { // We only monitor round 0 return; } - let Some(monitor) = &mut state.proposal_monitor else { + let Some(monitor) = monitor.as_mut() else { warn!( %proposed_value.height, %proposed_value.round, @@ -108,7 +170,7 @@ fn record_proposal_in_monitor(state: &mut State, proposed_value: &ProposedValue< return; }; - // Sanity checks - should always hold + // Sanity checks - should always hold on the live path if monitor.height != proposed_value.height || monitor.proposer != proposed_value.proposer { warn!( monitor.height = %monitor.height, @@ -142,13 +204,15 @@ struct HandlerContext<'a, 'b> { current_validator_set: ValidatorSet, proposer_selector: &'a dyn ProposerSelector, max_pending_proposals: usize, + /// Block finalized at the height before `current_height`, when the node has one. + previous_block: Option, } async fn on_received_proposal_part( context: HandlerContext<'_, '_>, from: PeerId, part: StreamMessage, -) -> eyre::Result>> { +) -> eyre::Result { let (part_type, part_size) = match &part.content { StreamContent::Data(part) => (part.get_type(), part.size_bytes()), StreamContent::Fin => ("end of stream", 0), @@ -159,24 +223,88 @@ async fn on_received_proposal_part( "Received proposal part" ); + // Capture the stream key before `insert` consumes `part`, so a completed + // stream can be marked closed once it leaves `streams`. + let stream_id = part.stream_id.clone(); + // Check if we have a full proposal let parts = match context.streams_map.insert(from, part) { InsertResult::Complete(parts) => parts, - InsertResult::Pending => return Ok(None), + InsertResult::Pending => return Ok(ReceivedPart::None), // Benign: an old proposal part re-circulating on the network. Dropped // without warning — not peer misbehaviour. - InsertResult::Stale => return Ok(None), + InsertResult::Stale => return Ok(ReceivedPart::None), InsertResult::Invalid(e) => { warn!(%from, error = %e, "Rejecting stream message"); - return Ok(None); + return Ok(ReceivedPart::None); } }; - // Process complete proposal parts, validate and assemble them into a block. - let block = process_proposal_parts((&context).into(), parts).await?; + // The stream has now left `streams`. Process it, then close its key on every + // disposition except a transient `Deferred` decline — including on error. A + // resurfaced straggler of a stream we completed (whether retained, rejected, + // or failed mid-validation/-storage) must not reopen a height=None slot held + // until the age timer; a genuine retry restreams under a fresh stream id, + // which `closed_keys` does not block. + let disposition = handle_complete_parts(&context, parts, from).await; + + if !matches!(disposition, Ok(Disposition::Deferred)) { + context.streams_map.mark_closed(from, stream_id); + } + + match disposition? { + Disposition::Assembled(value) => Ok(ReceivedPart::Assembled(*value)), + Disposition::BufferedPending(height) => Ok(ReceivedPart::BufferedPending(height)), + Disposition::Terminal | Disposition::Deferred => Ok(ReceivedPart::None), + } +} - let Some(mut block) = block else { - return Ok(None); +/// Outcome of fully handling a completed stream's parts. +enum Disposition { + /// Current-height proposal assembled into a block, validated, and stored as + /// an undecided block; carries the resulting `ProposedValue`. + Assembled(Box>), + /// A round-0 proposal for a future height was buffered as pending. + /// Terminal for stream-key purposes: the key may be closed, + /// exactly as for any other stored-pending stream. + BufferedPending(Height), + /// Terminal non-block disposition (stored pending but out of monitor scope, + /// ignored past-height, rejected, or assembly-failed); the stream key may + /// be closed. + Terminal, + /// Transient decline; the stream key must stay open for re-admission. + Deferred, +} + +/// Classifies a completed set of proposal parts and, for a current-height +/// proposal, validates and stores it as an undecided block. +/// +/// Errors (engine unreachable, store failure) propagate to the caller, which +/// closes the stream key regardless of whether processing succeeded — the parts +/// already left `streams`, and a deterministic re-delivery would fail the same +/// way while a genuine retry restreams under a fresh stream id. +async fn handle_complete_parts( + context: &HandlerContext<'_, '_>, + parts: ProposalParts, + from: PeerId, +) -> eyre::Result { + // Captured before `parts` is consumed below. + let parts_height = parts.height(); + let parts_round = parts.round(); + let current_height = context.current_height; + + let mut block = match process_proposal_parts(context.into(), parts).await? { + PartsOutcome::Block(block) => *block, + // The proposal monitor only cares about buffered future-height, round-0 proposals. + // The rest fall through to `Terminal` below. + PartsOutcome::StoredPending + if parts_height > current_height && parts_round.as_i64() == 0 => + { + return Ok(Disposition::BufferedPending(parts_height)); + } + outcome if outcome.is_terminal() => return Ok(Disposition::Terminal), + // The only non-terminal outcome is a transient `Deferred` decline. + _ => return Ok(Disposition::Deferred), }; // Validate the block @@ -185,11 +313,39 @@ async fn on_received_proposal_part( &context.metrics, &context.store, &mut block, + context.previous_block.as_ref(), from, ) .await?; - let proposed_value = ProposedValue::from(&block); + // Don't key a non-canonical block into the undecided table; its invalid + // record was already persisted by `establish_block_validity`. + if !block.may_be_stored_as_undecided() { + warn!( + height = %block.height, + round = %block.round, + block_hash = %block.self_reported_block_hash(), + "Proposal block self-reported hash is not canonical; not storing as undecided", + ); + return Ok(Disposition::Terminal); + } + + // The block is stored with its execution-only validity below; only the prevote + // is downgraded when the proposer's timestamp is too far ahead of local time. + // This refuses a too-far-future proposal without ever persisting it Invalid, so + // it stays adoptable via a commit certificate with no restart. + // + // The gate re-runs on every offer, including a restreamed re-proposal: the vote + // must be recomputed against current local time each time (the verdict can relax + // to Valid as the clock advances), so it is intentionally not restream-guarded + // here or on the `started_round` path. + let validity = skew_gate::validity_for( + &block, + skew_gate::local_time_secs(), + &context.metrics, + SkewNilVoteSource::ReceivedProposal, + ); + let proposed_value = block.to_proposed_value_with_validity(validity); debug!( block_size = %block.size_bytes(), @@ -198,7 +354,7 @@ async fn on_received_proposal_part( ); // Store the full undecided block in the store - let block_hash = block.block_hash(); + let block_hash = block.self_reported_block_hash(); context.store.store_undecided_block(block).await.wrap_err_with(|| format!( @@ -207,12 +363,12 @@ async fn on_received_proposal_part( ) )?; - Ok(Some(proposed_value)) + Ok(Disposition::Assembled(Box::new(proposed_value))) } /// Validates a block received from a peer via the Engine API and records -/// the result. If the engine rejects the payload, an [`InvalidPayload`] -/// record is persisted by [`validate_consensus_block`] and the block's +/// the result. If a binding rule or the engine rejects the payload, an +/// [`InvalidPayload`] record is persisted by [`establish_block_validity`] and the block's /// validity is set to [`Validity::Invalid`]. The block is kept either way /// so that consensus can proceed with the correct validity information. async fn validate_block( @@ -220,11 +376,13 @@ async fn validate_block( metrics: &AppMetrics, store: &Store, block: &mut ConsensusBlock, + previous_block: Option<&ExecutionBlock>, from: PeerId, ) -> eyre::Result<()> { let validator = EnginePayloadValidator::new(engine, metrics); - let validity = validate_consensus_block(&validator, block, store, metrics) + let validity = establish_block_validity(&validator, block, previous_block, store, metrics) .await + .map(|verdict| verdict.validity()) .wrap_err_with(|| { format!( "Payload validation failed on block built after \ @@ -235,10 +393,16 @@ async fn validate_block( match validity { Validity::Invalid => { - error!("❌ Received invalid block: {}", block.block_hash()); + error!( + "❌ Received invalid block: {}", + block.self_reported_block_hash() + ); } Validity::Valid => { - debug!("✅ Received valid block: {}", block.block_hash()); + debug!( + "✅ Received valid block: {}", + block.self_reported_block_hash() + ); } } @@ -274,6 +438,43 @@ impl<'a> From<&'a HandlerContext<'_, '_>> for ProcessingContext<'a> { } } +/// Disposition of a completed set of proposal parts. +/// +/// Drives whether the originating stream key is recorded as closed: every +/// variant except [`PartsOutcome::Deferred`] is terminal and its key may be +/// closed so resurfaced duplicates cannot reopen a slot. `Deferred` is +/// transient — the proposal could become storable later — so its key must stay +/// open for re-admission. +enum PartsOutcome { + /// Current-height proposal with a valid proposer and signature. Carries the + /// block for the caller to validate and store as an undecided block. Boxed + /// to keep the enum small (the block dwarfs the unit variants). + Block(Box), + /// Future-height/round proposal retained in the pending table. + StoredPending, + /// Proposal from a past height; ignored. + IgnoredPastHeight, + /// Current-height proposal rejected: wrong proposer or invalid signature. + RejectedInvalid, + /// Current-height proposal whose parts failed to assemble into a block. The + /// bytes are malformed, so reassembly is deterministic — an `InvalidPayload` + /// is recorded and the key may be closed; a resurfaced copy would only fail + /// the same way. + AssemblyFailed, + /// Valid proposal not retained right now, but for a transient reason: it is + /// too far in the future (the window will advance) or the pending table is + /// full (room will free up). The key is left open for re-admission. + Deferred, +} + +impl PartsOutcome { + /// Whether the originating stream key may be recorded as closed: true for + /// every disposition except a transient [`PartsOutcome::Deferred`] decline. + fn is_terminal(&self) -> bool { + !matches!(self, PartsOutcome::Deferred) + } +} + /// Process complete proposal parts, validating and assembling them into a block. /// /// - If the parts are for a past height, they are ignored. @@ -284,7 +485,7 @@ impl<'a> From<&'a HandlerContext<'_, '_>> for ProcessingContext<'a> { async fn process_proposal_parts( ctx: ProcessingContext<'_>, parts: ProposalParts, -) -> eyre::Result> { +) -> eyre::Result { let parts_height = parts.height(); let parts_round = parts.round(); let parts_proposer = parts.proposer(); @@ -300,12 +501,12 @@ async fn process_proposal_parts( "Received proposal from a previous height, ignoring" ); - return Ok(None); + return Ok(PartsOutcome::IgnoredPastHeight); } // Store future proposals parts in pending without validation if parts_height > ctx.current_height || parts_round > ctx.current_round { - maybe_store_pending_proposal( + let stored = maybe_store_pending_proposal( ctx.store, ctx.metrics, ctx.current_height, @@ -315,7 +516,11 @@ async fn process_proposal_parts( ) .await?; - return Ok(None); + if stored { + return Ok(PartsOutcome::StoredPending); + } + + return Ok(PartsOutcome::Deferred); } debug_assert_eq!(parts_height, ctx.current_height); @@ -325,7 +530,7 @@ async fn process_proposal_parts( resolve_expected_proposer(ctx.proposer_selector, ctx.current_validator_set, &parts); if !validate_proposal_parts(&parts, expected_proposer, ctx.signing_provider).await { - return Ok(None); + return Ok(PartsOutcome::RejectedInvalid); } // Assemble the block @@ -341,23 +546,29 @@ async fn process_proposal_parts( ctx.metrics .inc_invalid_payloads_count(InvalidPayloadSource::AssemblyFailure); let invalid = InvalidPayload::new_from_parts(&parts, &e.to_string()); - ctx.store.append_invalid_payload(invalid).await.wrap_err_with(|| { - format!( - "Failed to store invalid payload after assembling block from parts (height={parts_height}, round={parts_round}, proposer={parts_proposer})", - ) - })?; - return Err(e.wrap_err(format!( - "Failed to assemble block from parts (height={parts_height}, round={parts_round}, proposer={parts_proposer})", - ))); + persist_invalid_payload_best_effort( + ctx.store, + invalid, + parts_height, + parts_round, + parts_proposer, + ) + .await; + return Ok(PartsOutcome::AssemblyFailed); } }; - debug!("Block hash: {}", block.block_hash()); + debug!("Block hash: {}", block.self_reported_block_hash()); - Ok(Some(block)) + Ok(PartsOutcome::Block(Box::new(block))) } -/// Store a pending proposal if it's not too far in the future +/// Store a pending proposal if it's not too far in the future. +/// +/// Returns `true` if the proposal was retained in the pending table, `false` if +/// it was transiently declined — either too far in the future, or the pending +/// table was full. A declined proposal may become storable later (the window +/// advances, the table frees), so its stream key must be left open. async fn maybe_store_pending_proposal( store: &Store, metrics: &AppMetrics, @@ -365,7 +576,7 @@ async fn maybe_store_pending_proposal( current_round: Round, max_pending_proposals: usize, parts: ProposalParts, -) -> eyre::Result<()> { +) -> eyre::Result { // max_pending_proposals > 0 (asserted at construction); fits in u64 on 64-bit targets #[allow(clippy::cast_possible_truncation, clippy::arithmetic_side_effects)] let max_future_height = current_height.increment_by(max_pending_proposals as u64 - 1); @@ -381,7 +592,7 @@ async fn maybe_store_pending_proposal( max_height = %max_future_height, "Received proposal for a height too far in the future, ignoring" ); - return Ok(()); + return Ok(false); } debug!( @@ -393,8 +604,8 @@ async fn maybe_store_pending_proposal( "Storing pending proposal for a future height/round" ); - // Store the parts for future processing - store + // Store the parts for future processing. `false` means the table was full. + let stored = store .store_pending_proposal_parts(parts, max_pending_proposals, current_height) .await .wrap_err("Failed to store pending proposal parts")?; @@ -407,24 +618,138 @@ async fn maybe_store_pending_proposal( metrics.observe_pending_proposal_parts_count(pending_count); - Ok(()) + Ok(stored) } #[cfg(test)] mod tests { use super::*; + use alloy_rpc_types_engine::{ExecutionPayloadV3, PayloadStatus, PayloadStatusEnum}; + use arbitrary::{Arbitrary, Unstructured}; use arc_consensus_db::{DbMetrics, DbUpgrade}; use arc_consensus_types::proposer::RoundRobin; - use arc_consensus_types::Validator; + use arc_consensus_types::{Address, Validator}; + use arc_eth_engine::engine::{MockEngineAPI, MockEthereumAPI}; use arc_signer::local::{LocalSigningProvider, PrivateKey}; use bytesize::ByteSize; use tempfile::tempdir; - use crate::handlers::test_utils::signed_parts_without_data; + use crate::handlers::test_utils::{signed_parts_without_data, signed_stream_without_data}; + use crate::proposal_parts::prepare_stream; + use crate::streaming::new_stream_id; + + impl ReceivedPart { + fn as_assembled(&self) -> Option<&ProposedValue> { + match self { + ReceivedPart::Assembled(v) => Some(v), + _ => None, + } + } + + fn is_none(&self) -> bool { + matches!(self, ReceivedPart::None) + } + + fn is_buffered_pending(&self) -> bool { + matches!(self, ReceivedPart::BufferedPending(_)) + } + } + + use arc_consensus_types::{BlockHash, Value}; + use malachitebft_app_channel::app::types::core::Validity; + use std::time::SystemTime; + + fn proposed_value( + height: Height, + round: Round, + proposer: Address, + validity: Validity, + seed: u8, + ) -> ProposedValue { + ProposedValue { + height, + round, + valid_round: Round::Nil, + proposer, + value: Value::new(BlockHash::repeat_byte(seed)), + validity, + } + } + + #[test] + fn record_proposal_in_monitor_records_matching_live_proposal() { + let height = Height::new(2); + let proposer = Address::new([0x42; 20]); + let mut monitor = Some(ProposalMonitor::new(height, proposer, SystemTime::now())); + + let pv = proposed_value(height, Round::new(0), proposer, Validity::Valid, 0xAA); + record_proposal_in_monitor(&mut monitor, Round::new(0), &pv); + + assert_eq!(monitor.unwrap().value_id, Some(pv.value.id())); + } + + #[test] + fn record_proposal_in_monitor_skips_invalid_proposal() { + let height = Height::new(2); + let proposer = Address::new([0x42; 20]); + let mut monitor = Some(ProposalMonitor::new(height, proposer, SystemTime::now())); + + let pv = proposed_value(height, Round::new(0), proposer, Validity::Invalid, 0xAA); + record_proposal_in_monitor(&mut monitor, Round::new(0), &pv); + + assert!(monitor.unwrap().value_id.is_none()); + } + + #[test] + fn record_proposal_in_monitor_skips_off_round0() { + let height = Height::new(2); + let proposer = Address::new([0x42; 20]); + let mut monitor = Some(ProposalMonitor::new(height, proposer, SystemTime::now())); + + // Node has advanced past round 0 (the "too-late" path). + let pv = proposed_value(height, Round::new(0), proposer, Validity::Valid, 0xAA); + record_proposal_in_monitor(&mut monitor, Round::new(1), &pv); + + assert!(monitor.unwrap().value_id.is_none()); + } + + #[test] + fn record_proposal_in_monitor_noop_when_monitor_absent() { + let height = Height::new(2); + let proposer = Address::new([0x42; 20]); + let mut monitor: Option = None; + + let pv = proposed_value(height, Round::new(0), proposer, Validity::Valid, 0xAA); + record_proposal_in_monitor(&mut monitor, Round::new(0), &pv); + + assert!(monitor.is_none()); + } + + #[test] + fn record_proposal_in_monitor_skips_on_height_mismatch() { + let proposer = Address::new([0x42; 20]); + let mut monitor = Some(ProposalMonitor::new( + Height::new(2), + proposer, + SystemTime::now(), + )); + + // Monitor is for height 2 but the proposal is for height 3. + let pv = proposed_value( + Height::new(3), + Round::new(0), + proposer, + Validity::Valid, + 0xAA, + ); + record_proposal_in_monitor(&mut monitor, Round::new(0), &pv); + + assert!(monitor.unwrap().value_id.is_none()); + } #[tokio::test] - async fn process_proposal_parts_increments_on_assembly_failure() { + async fn process_proposal_parts_assembly_failure_is_terminal() { let dir = tempdir().unwrap(); let store = Store::open( dir.path().join("db"), @@ -458,9 +783,803 @@ mod tests { max_pending_proposals: 10, }; - let result = process_proposal_parts(ctx, parts).await; + let outcome = process_proposal_parts(ctx, parts).await.unwrap(); - assert!(result.is_err()); + assert!(matches!(outcome, PartsOutcome::AssemblyFailed)); + assert!( + outcome.is_terminal(), + "assembly failure is deterministic — its key must be closed" + ); assert_eq!(metrics.get_invalid_payloads_count(), 1); } + + /// Owned fixtures backing a [`ProcessingContext`]; each test borrows these. + struct Fixtures { + _dir: tempfile::TempDir, + store: Store, + metrics: AppMetrics, + provider: ArcSigningProvider, + validator_set: ValidatorSet, + signing_key: PrivateKey, + } + + async fn fixtures() -> Fixtures { + let dir = tempdir().unwrap(); + let store = Store::open( + dir.path().join("db"), + DbMetrics::default(), + DbUpgrade::Skip, + ByteSize::mib(64), + ) + .await + .unwrap(); + + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator = Validator::new(signing_key.public_key(), 1); + let validator_set = ValidatorSet::new(vec![validator]); + let provider = ArcSigningProvider::Local(LocalSigningProvider::new(signing_key.clone())); + + Fixtures { + _dir: dir, + store, + metrics: AppMetrics::default(), + provider, + validator_set, + signing_key, + } + } + + #[tokio::test] + async fn process_proposal_parts_past_height_is_ignored() { + let f = fixtures().await; + let selector = RoundRobin; + let ctx = ProcessingContext { + store: &f.store, + metrics: &f.metrics, + signing_provider: &f.provider, + current_height: Height::new(5), + current_round: Round::new(0), + current_validator_set: &f.validator_set, + proposer_selector: &selector, + max_pending_proposals: 10, + }; + + let parts = signed_parts_without_data(Height::new(3), Round::new(0), &f.signing_key).await; + let outcome = process_proposal_parts(ctx, parts).await.unwrap(); + + assert!(matches!(outcome, PartsOutcome::IgnoredPastHeight)); + assert!(outcome.is_terminal()); + assert_eq!(f.store.get_pending_proposal_parts_count().await.unwrap(), 0); + } + + #[tokio::test] + async fn process_proposal_parts_future_in_range_is_stored_pending() { + let f = fixtures().await; + let selector = RoundRobin; + let ctx = ProcessingContext { + store: &f.store, + metrics: &f.metrics, + signing_provider: &f.provider, + current_height: Height::new(5), + current_round: Round::new(0), + current_validator_set: &f.validator_set, + proposer_selector: &selector, + max_pending_proposals: 10, + }; + + let parts = signed_parts_without_data(Height::new(6), Round::new(0), &f.signing_key).await; + let outcome = process_proposal_parts(ctx, parts).await.unwrap(); + + assert!(matches!(outcome, PartsOutcome::StoredPending)); + assert!(outcome.is_terminal()); + assert_eq!(f.store.get_pending_proposal_parts_count().await.unwrap(), 1); + } + + #[tokio::test] + async fn process_proposal_parts_too_far_future_is_deferred_and_not_stored() { + let f = fixtures().await; + let selector = RoundRobin; + // max_pending_proposals = 2 → allowed heights are 5 and 6; 10 is too far. + let ctx = ProcessingContext { + store: &f.store, + metrics: &f.metrics, + signing_provider: &f.provider, + current_height: Height::new(5), + current_round: Round::new(0), + current_validator_set: &f.validator_set, + proposer_selector: &selector, + max_pending_proposals: 2, + }; + + let parts = signed_parts_without_data(Height::new(10), Round::new(0), &f.signing_key).await; + let outcome = process_proposal_parts(ctx, parts).await.unwrap(); + + assert!(matches!(outcome, PartsOutcome::Deferred)); + assert!( + !outcome.is_terminal(), + "a too-far proposal is transient — its key must stay open" + ); + assert_eq!(f.store.get_pending_proposal_parts_count().await.unwrap(), 0); + } + + #[tokio::test] + async fn process_proposal_parts_pending_table_full_is_deferred_and_not_stored() { + let f = fixtures().await; + + // Fill the pending table (capacity 2) with two in-range future proposals. + let p1 = signed_parts_without_data(Height::new(6), Round::new(0), &f.signing_key).await; + let p2 = signed_parts_without_data(Height::new(6), Round::new(1), &f.signing_key).await; + assert!(f + .store + .store_pending_proposal_parts(p1, 2, Height::new(5)) + .await + .unwrap()); + assert!(f + .store + .store_pending_proposal_parts(p2, 2, Height::new(5)) + .await + .unwrap()); + assert_eq!(f.store.get_pending_proposal_parts_count().await.unwrap(), 2); + + let selector = RoundRobin; + let ctx = ProcessingContext { + store: &f.store, + metrics: &f.metrics, + signing_provider: &f.provider, + current_height: Height::new(5), + current_round: Round::new(0), + current_validator_set: &f.validator_set, + proposer_selector: &selector, + max_pending_proposals: 2, + }; + + // Another in-range future proposal: valid, but the table is full. + let p3 = signed_parts_without_data(Height::new(6), Round::new(2), &f.signing_key).await; + let outcome = process_proposal_parts(ctx, p3).await.unwrap(); + + assert!(matches!(outcome, PartsOutcome::Deferred)); + assert!( + !outcome.is_terminal(), + "a table-full decline is transient — its key must stay open" + ); + assert_eq!( + f.store.get_pending_proposal_parts_count().await.unwrap(), + 2, + "a table-full proposal must not be stored" + ); + } + + // --- on_received_proposal_part: full-handler tests (mock Engine API) --- + // + // These exercise the `mark_closed` glue end-to-end: a completed stream whose + // proposal reaches a terminal disposition has its key closed (so resurfaced + // duplicates are dropped), while a transiently-deferred proposal leaves the + // key open. The current-height path also drives block validation through a + // mocked Engine API. + + /// A small, deterministic execution payload that SSZ round-trips through + /// `assemble_block_from_parts`. + /// Builds a payload that carries `height` as its block number, so a block + /// built from it is bound to that height. + fn dummy_payload(height: Height) -> ExecutionPayloadV3 { + let mut u = Unstructured::new(&[0u8; 1024]); + let mut payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + payload.payload_inner.payload_inner.block_number = height.as_u64(); + payload + } + + /// Build a [`ConsensusBlock`] proposed by the fixtures' single validator, so + /// proposer + signature validation passes on the current-height path. + fn block_from(f: &Fixtures, height: Height, round: Round) -> ConsensusBlock { + ConsensusBlock { + height, + round, + valid_round: Round::Nil, + proposer: Address::from_public_key(&f.signing_key.public_key()), + validity: Validity::Valid, + execution_payload: dummy_payload(height), + signature: None, + } + } + + fn make_ctx<'a, 'b>( + f: &Fixtures, + engine: &'a Engine, + selector: &'a dyn ProposerSelector, + streams_map: &'b mut PartStreamsMap, + current_height: Height, + max_pending_proposals: usize, + previous_block: Option, + ) -> HandlerContext<'a, 'b> { + HandlerContext { + engine, + store: f.store.clone(), + metrics: f.metrics.clone(), + signing_provider: f.provider.clone(), + streams_map, + current_height, + current_round: Round::new(0), + current_validator_set: f.validator_set.clone(), + proposer_selector: selector, + max_pending_proposals, + previous_block, + } + } + + /// Feed every message through `on_received_proposal_part`, rebuilding a fresh + /// `HandlerContext` each time (it is consumed by value), and return the result + /// of the completing (last) message. + #[allow(clippy::too_many_arguments)] + async fn feed_stream( + f: &Fixtures, + engine: &Engine, + selector: &dyn ProposerSelector, + streams_map: &mut PartStreamsMap, + current_height: Height, + max_pending: usize, + from: PeerId, + messages: &[StreamMessage], + ) -> eyre::Result { + feed_stream_with_previous_block( + f, + engine, + selector, + streams_map, + current_height, + max_pending, + from, + messages, + None, + ) + .await + } + + /// [`feed_stream`] with a predecessor in the context, so the parent rule applies. + #[allow(clippy::too_many_arguments)] + async fn feed_stream_with_previous_block( + f: &Fixtures, + engine: &Engine, + selector: &dyn ProposerSelector, + streams_map: &mut PartStreamsMap, + current_height: Height, + max_pending: usize, + from: PeerId, + messages: &[StreamMessage], + previous_block: Option, + ) -> eyre::Result { + let mut last = ReceivedPart::None; + for msg in messages { + let ctx = make_ctx( + f, + engine, + selector, + streams_map, + current_height, + max_pending, + previous_block, + ); + last = on_received_proposal_part(ctx, from, msg.clone()).await?; + } + Ok(last) + } + + /// A proposal for the current height whose payload belongs at another height: + /// the engine never sees it, and consensus gets an `Invalid` verdict. + #[tokio::test] + async fn on_received_proposal_part_rejects_a_payload_from_another_height() { + let f = fixtures().await; + let selector = RoundRobin; + let from = PeerId::random(); + let height = Height::new(5); + let round = Round::new(0); + + let mut engine_mock = MockEngineAPI::new(); + engine_mock.expect_new_payload().times(0); + let engine = Engine::new(Box::new(engine_mock), Box::new(MockEthereumAPI::new())); + + // The payload reports block number 2. Its self-reported hash is canonical, + // so the row can still be keyed under it. + let mut block = block_from(&f, height, round); + block + .execution_payload + .payload_inner + .payload_inner + .block_number = 2; + block + .execution_payload + .payload_inner + .payload_inner + .block_hash = + arc_consensus_types::block::canonical_block_hash(&block.execution_payload) + .expect("recompute canonical hash"); + + let stream_id = new_stream_id(height, round, 0); + let (messages, _sig) = prepare_stream(stream_id.clone(), &f.provider, &block) + .await + .unwrap(); + + let mut streams_map = PartStreamsMap::new(height, 1); + let result = feed_stream( + &f, + &engine, + &selector, + &mut streams_map, + height, + 10, + from, + &messages, + ) + .await + .unwrap(); + + let proposed = result + .as_assembled() + .expect("the verdict must reach consensus"); + assert_eq!(proposed.validity, Validity::Invalid); + assert_eq!(f.metrics.get_invalid_payloads_count(), 1); + } + + /// A proposal for the current height whose payload reports that height but + /// extends some other block. The height rule sees nothing wrong, so only the + /// parent rule rejects it, and the engine still never sees it. + #[tokio::test] + async fn on_received_proposal_part_rejects_a_payload_that_extends_another_block() { + let f = fixtures().await; + let selector = RoundRobin; + let from = PeerId::random(); + let height = Height::new(5); + let round = Round::new(0); + + let mut engine_mock = MockEngineAPI::new(); + engine_mock.expect_new_payload().times(0); + let engine = Engine::new(Box::new(engine_mock), Box::new(MockEthereumAPI::new())); + + // The immediate predecessor, and not the block the payload extends. + let previous_block = ExecutionBlock { + block_hash: arc_consensus_types::B256::repeat_byte(0xAB), + block_number: height.as_u64() - 1, + parent_hash: arc_consensus_types::B256::ZERO, + timestamp: 0, + }; + + let mut block = block_from(&f, height, round); + block + .execution_payload + .payload_inner + .payload_inner + .block_number = height.as_u64(); + block + .execution_payload + .payload_inner + .payload_inner + .parent_hash = arc_consensus_types::B256::repeat_byte(0xCD); + block + .execution_payload + .payload_inner + .payload_inner + .block_hash = + arc_consensus_types::block::canonical_block_hash(&block.execution_payload) + .expect("recompute canonical hash"); + + let stream_id = new_stream_id(height, round, 0); + let (messages, _sig) = prepare_stream(stream_id.clone(), &f.provider, &block) + .await + .unwrap(); + + let mut streams_map = PartStreamsMap::new(height, 1); + let result = feed_stream_with_previous_block( + &f, + &engine, + &selector, + &mut streams_map, + height, + 10, + from, + &messages, + Some(previous_block), + ) + .await + .unwrap(); + + let proposed = result + .as_assembled() + .expect("the verdict must reach consensus"); + assert_eq!(proposed.validity, Validity::Invalid); + assert_eq!( + f.metrics + .get_invalid_payloads_count_by_source(InvalidPayloadSource::PayloadParent), + 1, + ); + } + + #[tokio::test] + async fn on_received_proposal_part_current_height_validates_stores_and_closes_key() { + let f = fixtures().await; + let selector = RoundRobin; + let from = PeerId::random(); + let height = Height::new(5); + let round = Round::new(0); + + // Engine accepts the payload as valid. + let mut engine_mock = MockEngineAPI::new(); + engine_mock.expect_new_payload().returning(|_, _, _, _| { + Ok(PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: None, + }) + }); + let engine = Engine::new(Box::new(engine_mock), Box::new(MockEthereumAPI::new())); + + let block = block_from(&f, height, round); + let stream_id = new_stream_id(height, round, 0); + let (messages, _sig) = prepare_stream(stream_id.clone(), &f.provider, &block) + .await + .unwrap(); + + let mut streams_map = PartStreamsMap::new(height, 1); + let result = feed_stream( + &f, + &engine, + &selector, + &mut streams_map, + height, + 10, + from, + &messages, + ) + .await + .unwrap(); + + let proposed = result + .as_assembled() + .expect("current-height proposal should yield a ProposedValue"); + assert_eq!(proposed.height, height); + assert_eq!(proposed.round, round); + assert!( + streams_map.is_closed(from, &stream_id), + "key must be closed once the undecided block is stored" + ); + + // A resurfaced straggler (a data part) for the same stream is dropped and + // does not reopen a slot. + let straggler = messages[1].clone(); + let ctx = make_ctx(&f, &engine, &selector, &mut streams_map, height, 10, None); + assert!(on_received_proposal_part(ctx, from, straggler) + .await + .unwrap() + .is_none()); + } + + /// A current-height proposal whose self-reported hash is not canonical and is + /// rejected by the engine is not stored as an undecided block: the stream + /// reaches a terminal disposition and its key is closed. + #[tokio::test] + async fn on_received_proposal_part_non_canonical_invalid_block_is_not_stored() { + use crate::store::repositories::UndecidedBlocksRepository; + + let f = fixtures().await; + let selector = RoundRobin; + let from = PeerId::random(); + let height = Height::new(5); + let round = Round::new(0); + + // The engine rejects the payload. + let mut engine_mock = MockEngineAPI::new(); + engine_mock.expect_new_payload().returning(|_, _, _, _| { + Ok(PayloadStatus { + status: PayloadStatusEnum::Invalid { + validation_error: "block hash mismatch".into(), + }, + latest_valid_hash: None, + }) + }); + let engine = Engine::new(Box::new(engine_mock), Box::new(MockEthereumAPI::new())); + + // `block_from`'s payload carries a non-canonical self-reported hash. + let block = block_from(&f, height, round); + assert!(!block.self_reported_hash_is_canonical()); + let stream_id = new_stream_id(height, round, 0); + let (messages, _sig) = prepare_stream(stream_id.clone(), &f.provider, &block) + .await + .unwrap(); + + let mut streams_map = PartStreamsMap::new(height, 1); + let result = feed_stream( + &f, + &engine, + &selector, + &mut streams_map, + height, + 10, + from, + &messages, + ) + .await + .unwrap(); + + assert!( + result.is_none(), + "a non-canonical invalid proposal yields no ProposedValue" + ); + assert!( + streams_map.is_closed(from, &stream_id), + "a terminal disposition must close the stream key" + ); + assert!( + f.store + .get_by_round(height, round) + .await + .unwrap() + .is_empty(), + "a non-canonical invalid block must not be stored as undecided" + ); + assert_eq!(f.metrics.get_invalid_payloads_count(), 1); + } + + #[tokio::test] + async fn on_received_proposal_part_future_in_range_stores_pending_and_closes_key() { + let f = fixtures().await; + let selector = RoundRobin; + let from = PeerId::random(); + let current_height = Height::new(5); + let future_height = Height::new(6); // within range for max_pending = 10 + + // The future path stores without validation: the Engine must not be called. + let engine = Engine::new( + Box::new(MockEngineAPI::new()), + Box::new(MockEthereumAPI::new()), + ); + + let block = block_from(&f, future_height, Round::new(0)); + let stream_id = new_stream_id(future_height, Round::new(0), 0); + let (messages, _sig) = prepare_stream(stream_id.clone(), &f.provider, &block) + .await + .unwrap(); + + let mut streams_map = PartStreamsMap::new(current_height, 1); + let result = feed_stream( + &f, + &engine, + &selector, + &mut streams_map, + current_height, + 10, + from, + &messages, + ) + .await + .unwrap(); + + assert!( + result.is_buffered_pending(), + "a round-0 future-height proposal buffered as pending must report \ + BufferedPending so its receive time can be recorded" + ); + assert!( + streams_map.is_closed(from, &stream_id), + "a stored-pending (terminal) stream key must be closed" + ); + assert_eq!(f.store.get_pending_proposal_parts_count().await.unwrap(), 1); + } + + #[tokio::test] + async fn on_received_proposal_part_too_far_future_is_deferred_and_key_left_open() { + let f = fixtures().await; + let selector = RoundRobin; + let from = PeerId::random(); + let current_height = Height::new(5); + let too_far_height = Height::new(10); // max_pending = 2 allows only heights 5, 6 + + let engine = Engine::new( + Box::new(MockEngineAPI::new()), + Box::new(MockEthereumAPI::new()), + ); + + let block = block_from(&f, too_far_height, Round::new(0)); + let stream_id = new_stream_id(too_far_height, Round::new(0), 0); + let (messages, _sig) = prepare_stream(stream_id.clone(), &f.provider, &block) + .await + .unwrap(); + + let mut streams_map = PartStreamsMap::new(current_height, 1); + let result = feed_stream( + &f, + &engine, + &selector, + &mut streams_map, + current_height, + 2, + from, + &messages, + ) + .await + .unwrap(); + + assert!(result.is_none()); + assert!( + !streams_map.is_closed(from, &stream_id), + "a transiently-deferred (Deferred) stream key must stay open" + ); + assert_eq!( + f.store.get_pending_proposal_parts_count().await.unwrap(), + 0, + "a too-far proposal must not be stored" + ); + } + + #[tokio::test] + async fn on_received_proposal_part_assembly_failure_closes_key() { + let f = fixtures().await; + let selector = RoundRobin; + let from = PeerId::random(); + let height = Height::new(5); + let round = Round::new(0); + + // Assembly fails before validation, so the Engine must never be called: + // an expectation-less mock panics if it is. + let engine = Engine::new( + Box::new(MockEngineAPI::new()), + Box::new(MockEthereumAPI::new()), + ); + + let stream_id = new_stream_id(height, round, 0); + let messages = + signed_stream_without_data(stream_id.clone(), height, round, &f.signing_key).await; + + let mut streams_map = PartStreamsMap::new(height, 1); + let result = feed_stream( + &f, + &engine, + &selector, + &mut streams_map, + height, + 10, + from, + &messages, + ) + .await + .unwrap(); + + assert!( + result.is_none(), + "a stream that fails to assemble yields no ProposedValue" + ); + assert!( + streams_map.is_closed(from, &stream_id), + "assembly failure is deterministic — its key must be closed" + ); + assert_eq!( + f.metrics.get_invalid_payloads_count(), + 1, + "an InvalidPayload must be recorded for the malformed parts" + ); + + // A resurfaced straggler for the same stream is dropped and does not + // reopen a slot. + let straggler = messages[1].clone(); + let ctx = make_ctx(&f, &engine, &selector, &mut streams_map, height, 10, None); + assert!(on_received_proposal_part(ctx, from, straggler) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn on_received_proposal_part_engine_error_closes_key() { + let f = fixtures().await; + let selector = RoundRobin; + let from = PeerId::random(); + let height = Height::new(5); + let round = Round::new(0); + + // The Engine is unreachable: validation errors instead of returning a + // verdict. + let mut engine_mock = MockEngineAPI::new(); + engine_mock + .expect_new_payload() + .returning(|_, _, _, _| Err(eyre::eyre!("engine unreachable"))); + let engine = Engine::new(Box::new(engine_mock), Box::new(MockEthereumAPI::new())); + + let block = block_from(&f, height, round); + let stream_id = new_stream_id(height, round, 0); + let (messages, _sig) = prepare_stream(stream_id.clone(), &f.provider, &block) + .await + .unwrap(); + + let mut streams_map = PartStreamsMap::new(height, 1); + let result = feed_stream( + &f, + &engine, + &selector, + &mut streams_map, + height, + 10, + from, + &messages, + ) + .await; + + assert!(result.is_err(), "an engine error must propagate"); + assert!( + streams_map.is_closed(from, &stream_id), + "the key is closed even when validation errors, so a resurfaced \ + straggler cannot reopen a slot" + ); + } + + /// A live current-height proposal whose timestamp is far ahead of local time + /// is prevoted nil (Invalid vote), yet the block is still stored with its + /// execution-only Valid verdict so it stays adoptable via a commit certificate. + #[tokio::test] + async fn on_received_proposal_part_future_timestamp_votes_nil_but_stores_valid() { + use crate::store::repositories::UndecidedBlocksRepository; + + let f = fixtures().await; + let selector = RoundRobin; + let from = PeerId::random(); + let height = Height::new(5); + let round = Round::new(0); + + // The engine accepts the payload: it is execution-valid. + let mut engine_mock = MockEngineAPI::new(); + engine_mock.expect_new_payload().returning(|_, _, _, _| { + Ok(PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: None, + }) + }); + let engine = Engine::new(Box::new(engine_mock), Box::new(MockEthereumAPI::new())); + + // Stamp the block well beyond the skew threshold ahead of local time. + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut block = block_from(&f, height, round); + block + .execution_payload + .payload_inner + .payload_inner + .timestamp = now + 3600; + + let stream_id = new_stream_id(height, round, 0); + let (messages, _sig) = prepare_stream(stream_id.clone(), &f.provider, &block) + .await + .unwrap(); + + let mut streams_map = PartStreamsMap::new(height, 1); + let result = feed_stream( + &f, + &engine, + &selector, + &mut streams_map, + height, + 10, + from, + &messages, + ) + .await + .unwrap(); + + let proposed = result + .as_assembled() + .expect("the verdict must reach consensus"); + assert_eq!( + proposed.validity, + Validity::Invalid, + "a too-far-future proposal must be prevoted nil", + ); + + let stored = f.store.get_by_round(height, round).await.unwrap(); + assert_eq!( + stored.len(), + 1, + "the block must still be stored as undecided" + ); + assert_eq!( + stored[0].validity, + Validity::Valid, + "the stored block keeps its execution-only Valid verdict", + ); + } } diff --git a/crates/malachite-app/src/handlers/restream_proposal.rs b/crates/malachite-app/src/handlers/restream_proposal.rs index 267be361..4bb82d27 100644 --- a/crates/malachite-app/src/handlers/restream_proposal.rs +++ b/crates/malachite-app/src/handlers/restream_proposal.rs @@ -71,6 +71,13 @@ pub async fn restream_proposal( ) -> eyre::Result<()> { let (height, round) = (block.height, block.round); + if block.signature.is_none() { + return Err(eyre!( + "Refusing to restream block at height={height}, round={round} without a signature: \ + restreaming must reuse the block's original signature, never produce a new one" + )); + } + info!( %height, %round, valid_round = %block.valid_round, "Restreaming proposal, block size: {:?}, payload size: {:?}", @@ -220,7 +227,12 @@ mod tests { let mut mock = MockPublishProposalPart::new(); let stream_id = StreamId::new(Bytes::from_static(&[42; 20])); let signing_provider = LocalSigningProvider::new(PrivateKey::generate(&mut rng)); - let block = create_dummy_block(Height::new(10), Round::new(2), Round::Nil); + let mut block = create_dummy_block(Height::new(10), Round::new(2), Round::Nil); + + let (_, signature) = make_proposal_parts(&signing_provider, &block) + .await + .unwrap(); + block.signature = Some(signature); mock.expect_publish_proposal_part().returning(|_| Ok(())); @@ -229,6 +241,28 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn restream_proposal_without_signature_fails() { + let mut rng = rand::thread_rng(); + + let mock = MockPublishProposalPart::new(); + let stream_id = StreamId::new(Bytes::from_static(&[42; 20])); + let signing_provider = LocalSigningProvider::new(PrivateKey::generate(&mut rng)); + let block = create_dummy_block(Height::new(10), Round::new(2), Round::Nil); + assert!( + block.signature.is_none(), + "block must start without a signature" + ); + + let result = restream_proposal(mock, stream_id, &signing_provider, &block).await; + + let err = result.expect_err("restreaming a signatureless block should fail"); + assert!( + err.to_string().contains("without a signature"), + "error should explain the missing signature, got: {err}" + ); + } + /// Retrieve a stored block via `get_block_to_restream` and verify that proposal /// parts regenerated from it validate against the cached signature — i.e., the /// read path preserves the block's original signing inputs end-to-end. @@ -267,7 +301,7 @@ mod tests { assert!(validate_proposal_parts(&first_parts, expected_first, &provider).await); block.signature = Some(first_sig); - let block_hash = block.block_hash(); + let block_hash = block.self_reported_block_hash(); let stored_block = block.clone(); let mut mock_repo = MockUndecidedBlocksRepository::new(); diff --git a/crates/malachite-app/src/handlers/skew_gate.rs b/crates/malachite-app/src/handlers/skew_gate.rs new file mode 100644 index 00000000..54d32b07 --- /dev/null +++ b/crates/malachite-app/src/handlers/skew_gate.rs @@ -0,0 +1,300 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Proposer clock-skew gate for received proposals. +//! +//! When a proposal's header timestamp is more than +//! [`ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS`] ahead of local time, the gate +//! downgrades the node's prevote to `Invalid`. It only ever changes the vote: +//! the block's persisted (execution-only) validity is untouched, so a value that +//! later carries a commit certificate is still adopted via sync. Because the +//! `Invalid` travels with the `ProposedValue`, it also keeps the node from +//! deciding directly on that certificate — adoption goes around through sync. +//! Both the live-arrival path (`received_proposal_part`) and the buffered/ +//! early-arrival path (`started_round`) apply the gate here. +//! +//! The threshold, the timestamp predicate, and the local-clock read live in this +//! module: the clock-skew judgment is a consensus-layer concern (the execution +//! layer no longer performs it). + +use std::time::{SystemTime, UNIX_EPOCH}; + +use malachitebft_app_channel::app::types::core::Validity; +use tracing::warn; + +use crate::block::ConsensusBlock; +use crate::metrics::{AppMetrics, SkewNilVoteSource}; + +/// The maximum clock skew, in seconds, tolerated between a proposer's block +/// timestamp and a validating node's local clock. +const ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS: u64 = 30; + +/// Returns `true` when `header_timestamp` is more than +/// [`ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS`] ahead of `local_time` (both in +/// seconds since the Unix epoch). +/// +/// A timestamp at or before `local_time + threshold` — including any timestamp +/// in the past — is within tolerance. +fn header_timestamp_exceeds_skew(header_timestamp: u64, local_time: u64) -> bool { + header_timestamp > local_time.saturating_add(ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS) +} + +/// Local wall-clock time in whole seconds since the Unix epoch, or `None` if the +/// system clock is set before the epoch. +/// +/// Callers that cannot read the clock should skip the skew check rather than +/// treat the proposal as skewed, so a clock read failure never forces a nil-vote. +pub(crate) fn local_time_secs() -> Option { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|elapsed| elapsed.as_secs()) +} + +/// Applies the clock-skew gate: the validity to vote with, given a block's +/// execution verdict, the proposer's header timestamp, and local time. +/// +/// An execution-`Valid` block whose timestamp is more than the clock-skew +/// threshold ahead of `local_time` votes `Invalid` (nil). An execution-`Invalid` +/// block stays `Invalid` regardless of the clock. +pub(crate) fn apply( + execution_validity: Validity, + header_timestamp: u64, + local_time: u64, +) -> Validity { + if execution_validity.is_valid() && header_timestamp_exceeds_skew(header_timestamp, local_time) + { + Validity::Invalid + } else { + execution_validity + } +} + +/// The validity to vote with for `block`, applying the clock-skew gate against +/// `local_time`, recording and warning on a downgrade under `source`. +/// +/// When `local_time` is `None` (the system clock could not be read) the gate is +/// skipped and the block's execution validity is used, so a clock read failure +/// never forces a nil-vote. +pub(crate) fn validity_for( + block: &ConsensusBlock, + local_time: Option, + metrics: &AppMetrics, + source: SkewNilVoteSource, +) -> Validity { + let Some(now) = local_time else { + warn!( + height = %block.height, + round = %block.round, + "Local clock is before the Unix epoch; skipping the proposer clock-skew vote check", + ); + return block.validity; + }; + + let header_timestamp = block.execution_payload.timestamp(); + let validity = apply(block.validity, header_timestamp, now); + + // A downgrade (execution-Valid -> vote-Invalid) is the skew nil-vote: count it + // labelled by path and warn with the offset, so a skewed node is diagnosable + // rather than looking like an unexplained slow node. Reading the downgrade off + // `apply`'s output keeps the signal from drifting from the condition. This counts + // the downgrade decision, not a confirmed prevote: a later store failure (received + // path) or a consensus-side cap-drop can still discard the value. + if block.validity.is_valid() && !validity.is_valid() { + metrics.inc_clock_skew_nil_vote_count(source); + warn!( + height = %block.height, + round = %block.round, + header_timestamp, + local_time = now, + delta_secs = header_timestamp.saturating_sub(now), + "Prevoting nil: proposer timestamp too far ahead of local clock", + ); + } + + validity +} + +#[cfg(test)] +mod tests { + use super::*; + + use alloy_rpc_types_engine::ExecutionPayloadV3; + use arbitrary::{Arbitrary, Unstructured}; + use arc_consensus_types::{Address, Height, Round}; + + fn block_with_timestamp(timestamp: u64, validity: Validity) -> ConsensusBlock { + let mut u = Unstructured::new(&[0u8; 512]); + let mut payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + payload.payload_inner.payload_inner.timestamp = timestamp; + ConsensusBlock { + height: Height::new(1), + round: Round::new(0), + valid_round: Round::Nil, + proposer: Address::new([0u8; 20]), + validity, + execution_payload: payload, + signature: None, + } + } + + #[test] + fn apply_downgrades_execution_valid_when_skewed() { + let local_time = 1_000_000; + let skewed = local_time + ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS + 1; + assert_eq!( + apply(Validity::Valid, skewed, local_time), + Validity::Invalid + ); + } + + #[test] + fn apply_keeps_execution_valid_within_tolerance() { + let local_time = 1_000_000; + let at_threshold = local_time + ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS; + assert_eq!( + apply(Validity::Valid, at_threshold, local_time), + Validity::Valid, + ); + assert_eq!( + apply(Validity::Valid, local_time - 100, local_time), + Validity::Valid, + ); + } + + #[test] + fn apply_leaves_execution_invalid_invalid_regardless_of_clock() { + let local_time = 1_000_000; + let skewed = local_time + ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS + 1; + assert_eq!( + apply(Validity::Invalid, local_time, local_time), + Validity::Invalid, + ); + assert_eq!( + apply(Validity::Invalid, skewed, local_time), + Validity::Invalid, + ); + } + + #[test] + fn apply_saturates_near_u64_max() { + // local_time + threshold saturates at u64::MAX instead of overflowing, so + // the comparison never panics in debug builds; a timestamp at the saturated + // bound is within tolerance. + assert_eq!(apply(Validity::Valid, u64::MAX, u64::MAX), Validity::Valid); + assert_eq!( + apply(Validity::Valid, u64::MAX, u64::MAX - 1), + Validity::Valid, + ); + } + + #[test] + fn validity_for_skips_the_gate_when_local_time_is_unavailable() { + let metrics = AppMetrics::default(); + let local_time = 1_000_000u64; + let block = block_with_timestamp( + local_time + ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS + 100, + Validity::Valid, + ); + // No clock reading → gate skipped, execution validity preserved, nothing counted. + assert_eq!( + validity_for(&block, None, &metrics, SkewNilVoteSource::ReceivedProposal), + Validity::Valid, + ); + // With a lagging clock reading → skewed proposal is downgraded. + assert_eq!( + validity_for( + &block, + Some(local_time), + &metrics, + SkewNilVoteSource::ReceivedProposal + ), + Validity::Invalid, + ); + assert_eq!( + metrics.get_clock_skew_nil_vote_count_by_source(SkewNilVoteSource::ReceivedProposal), + 1, + ); + } + + #[test] + fn skew_nil_vote_is_counted_and_labelled_by_path() { + let metrics = AppMetrics::default(); + let local_time = 1_000_000u64; + let skewed = block_with_timestamp( + local_time + ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS + 1, + Validity::Valid, + ); + let within = block_with_timestamp( + local_time + ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS, + Validity::Valid, + ); + let exec_invalid = block_with_timestamp( + local_time + ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS + 1, + Validity::Invalid, + ); + + // Within tolerance → Valid vote, nothing counted. + assert_eq!( + validity_for( + &within, + Some(local_time), + &metrics, + SkewNilVoteSource::ReceivedProposal + ), + Validity::Valid, + ); + // Execution-invalid stays Invalid but is not a skew downgrade → not counted. + assert_eq!( + validity_for( + &exec_invalid, + Some(local_time), + &metrics, + SkewNilVoteSource::ReceivedProposal, + ), + Validity::Invalid, + ); + // Too-far-future on the live path → nil-vote, counted under that path. + assert_eq!( + validity_for( + &skewed, + Some(local_time), + &metrics, + SkewNilVoteSource::ReceivedProposal + ), + Validity::Invalid, + ); + // And on the buffered path → counted under the other label. + assert_eq!( + validity_for( + &skewed, + Some(local_time), + &metrics, + SkewNilVoteSource::StartedRound + ), + Validity::Invalid, + ); + + assert_eq!( + metrics.get_clock_skew_nil_vote_count_by_source(SkewNilVoteSource::ReceivedProposal), + 1, + ); + assert_eq!( + metrics.get_clock_skew_nil_vote_count_by_source(SkewNilVoteSource::StartedRound), + 1, + ); + } +} diff --git a/crates/malachite-app/src/handlers/started_round.rs b/crates/malachite-app/src/handlers/started_round.rs index 76e7730b..cd18fe45 100644 --- a/crates/malachite-app/src/handlers/started_round.rs +++ b/crates/malachite-app/src/handlers/started_round.rs @@ -25,15 +25,22 @@ use malachitebft_app_channel::Reply; use arc_consensus_types::proposer::ProposerSelector; use arc_consensus_types::{Address, ArcContext, Height, ProposalParts, Round, ValidatorSet}; use arc_eth_engine::engine::Engine; +use arc_eth_engine::json_structures::ExecutionBlock; use arc_signer::ArcSigningProvider; +use super::skew_gate; use crate::block::ConsensusBlock; -use crate::metrics::{AppMetrics, InvalidPayloadSource}; -use crate::payload::{validate_consensus_block, EnginePayloadValidator, PayloadValidator}; +use crate::metrics::{ + AppMetrics, InvalidPayloadSource, SkewNilVoteSource, TransientValidationSource, +}; +use crate::payload::{ + check_payload_binding, establish_block_validity, persist_invalid_payload_best_effort, + validate_consensus_block, EnginePayloadValidator, PayloadValidator, +}; use crate::proposal_parts::{ assemble_block_from_parts, resolve_expected_proposer, validate_proposal_parts, }; -use crate::state::State; +use crate::state::{attach_valid_proposal_value_ids, State}; use crate::store::repositories::{InvalidPayloadsRepository, UndecidedBlocksRepository}; use crate::store::Store; use arc_consensus_db::invalid_payloads::InvalidPayload; @@ -97,10 +104,19 @@ async fn on_started_round( assert!(round != Round::Nil, "Round cannot be Nil"); assert!(round >= state.current_round, "Round cannot go backwards"); + record_missed_rounds( + state.metrics(), + &state.ctx.proposer_selector, + state.validator_set(), + height, + state.current_round, + round, + ); + state.current_round = round; state.current_proposer = Some(proposer); - fetch_and_process_pending_proposals( + let proposals = fetch_and_process_pending_proposals( height, round, state.validator_set(), @@ -109,8 +125,53 @@ async fn on_started_round( engine, state.signing_provider(), state.metrics(), + state.previous_block.as_ref(), ) - .await + .await?; + + // Second half of the pending-parts recording: record the value id the + // early-arrived parts carry (engine-invalid blocks skipped). + attach_valid_proposal_value_ids(&mut state.proposal_monitor, height, round, &proposals); + + Ok(proposals) +} + +/// Increments the `consensus_round_missed` counter once for every round that was +/// started but skipped over between `prev_round` and `new_round`, attributing each +/// skipped round to the validator that round-robin would have made its proposer. +/// +/// `prev_round` is the height's previously started round (`state.current_round`), +/// with `Round::Nil` treated as round 0; the skipped rounds are the half-open +/// range `prev_round..new_round`, so the round that actually started is never +/// counted as missed. +/// +/// This is an at-least-once alerting signal, not an exact ledger. On a mid-height +/// restart `state.current_round` resets to `Round::Nil`, so a replayed +/// `StartedRound` for a round the node had already advanced past will re-count +/// rounds that were counted before the restart. The counter therefore never +/// under-counts missed rounds but may over-count across restarts — acceptable for +/// the alerting use case it serves. +fn record_missed_rounds( + metrics: &AppMetrics, + proposer_selector: &dyn ProposerSelector, + validator_set: &ValidatorSet, + height: Height, + prev_round: Round, + new_round: Round, +) { + let mut missed_round = prev_round.or(Round::Some(0)); + + while missed_round < new_round { + let missed_proposer = proposer_selector + .select_proposer(validator_set, height, missed_round) + .address; + + warn!(%missed_proposer, %height, %missed_round, "Consensus round missed"); + + metrics.inc_consensus_round_missed(missed_proposer); + + missed_round = missed_round.increment(); + } } #[allow(clippy::too_many_arguments)] @@ -123,6 +184,7 @@ async fn fetch_and_process_pending_proposals( engine: &Engine, signing_provider: &ArcSigningProvider, metrics: &AppMetrics, + previous_block: Option<&ExecutionBlock>, ) -> eyre::Result>> { let pending_parts = store .get_pending_proposal_parts(height, round) @@ -131,8 +193,12 @@ async fn fetch_and_process_pending_proposals( info!(%height, %round, "StartedRound: Found {} pending proposal parts", pending_parts.len()); - // Convert the pending proposal parts for the current round, - // into blocks and add them to undecided blocks table. + let payload_validator = EnginePayloadValidator::new(engine, metrics); + + // Validate each pending block with the engine and insert the row into + // the undecided table with the engine's verdict. Rows always reflect a + // real verdict; on a transient engine error we skip the insert. The + // parts are pruned when height advances past them. process_pending_proposal_parts( store, pending_parts, @@ -140,28 +206,61 @@ async fn fetch_and_process_pending_proposals( round, validator_set, proposer_selector, + &payload_validator, + store, signing_provider, metrics, + previous_block, ) .await .wrap_err("Failed to validate pending proposal parts")?; + // Re-validate undecided blocks that already exist in the store. This is + // needed after a restart, when the execution client may have lost the + // payloads from its in-memory tree and must be re-fed them. let blocks = validate_undecided_blocks( height, round, + previous_block, store, - &EnginePayloadValidator::new(engine, metrics), + &payload_validator, store, metrics, ) .await .wrap_err("failed to validate undecided blocks")?; - Ok(blocks.iter().map(ProposedValue::from).collect()) + // Apply the same vote-time clock-skew gate as the live-arrival path: a proposal + // whose parts arrived before its round started — or an undecided block that was + // stored, survived a crash, and is re-validated on recovery — must still be + // prevoted nil when its timestamp is too far ahead of local time. The stored + // block keeps its execution-only validity — only the vote is downgraded. + let local_time = skew_gate::local_time_secs(); + Ok(blocks + .iter() + .map(|block| { + let validity = skew_gate::validity_for( + block, + local_time, + metrics, + SkewNilVoteSource::StartedRound, + ); + block.to_proposed_value_with_validity(validity) + }) + .collect()) } /// Process the pending proposal parts for the current height, assembling them -/// into blocks and moving the blocks to the undecided table. +/// into blocks, validating each block against the execution client, and +/// moving the validated blocks to the undecided table with their engine +/// verdict. +/// +/// A row in the undecided table always reflects the engine's verdict at +/// insertion time: no placeholder `Valid` ever leaks. If the engine cannot +/// be reached (transport error, `SYNCING`/`ACCEPTED`, …) the pending parts +/// are left in place and pruned when height advances past them. BFT +/// liveness covers the gap: the round times out and a later proposer's +/// block gets decided — this specific proposal does not need to land. /// /// ## Important /// This function assumes that the pending parts are for the current height and round. @@ -173,8 +272,11 @@ async fn process_pending_proposal_parts( current_round: Round, validator_set: &ValidatorSet, proposer_selector: &dyn ProposerSelector, + payload_validator: &impl PayloadValidator, + invalid_payloads: &impl InvalidPayloadsRepository, signing_provider: &ArcSigningProvider, metrics: &AppMetrics, + previous_block: Option<&ExecutionBlock>, ) -> eyre::Result<()> { for parts in pending_parts { let (height, round, proposer) = (parts.height(), parts.round(), parts.proposer()); @@ -188,55 +290,81 @@ async fn process_pending_proposal_parts( continue; } - // NOTE: The block is initially assigned a default validity status - // (i.e., `Validity::Valid`), even though it has not yet been validated - // by the execution client. - // By inserting this block into the undecided blocks table, we are - // temporarily violating the assumption that all blocks in that table - // have been validated at least once by the execution client. - // This temporary inconsistency is acceptable here because all blocks - // in the undecided table are immediately validated by the subsequent - // `validate_undecided_blocks` in `AppMsg::StartedRound` handler. - match assemble_block_from_parts(&parts) { - Ok(block) => { - info!(%height, %round, %proposer, "Added pending block to undecided"); - - // Atomically remove from pending and store as undecided - // This ensures that if the process fails, the parts are not lost - remove_pending_parts_and_store_undecided_block(store, parts, block).await?; - } + let mut block = match assemble_block_from_parts(&parts) { + Ok(block) => block, Err(e) => { warn!(%height, %round, %proposer, "Failed to assemble block from pending parts: {e}"); metrics.inc_invalid_payloads_count(InvalidPayloadSource::AssemblyFailure); let invalid_payload = InvalidPayload::new_from_parts(&parts, &e.to_string()); - store.append_invalid_payload(invalid_payload).await.wrap_err_with(|| { - format!( - "Failed to store invalid payload after assembling block from pending parts (height={height}, round={round}, proposer={proposer})", - ) - })?; + persist_invalid_payload_best_effort( + store, + invalid_payload, + height, + round, + proposer, + ) + .await; + continue; } - } + }; + + // Engine verdict before insert: a transient engine error must not be + // recorded as a permanent `Invalid` verdict against this block. + let validity = match establish_block_validity( + payload_validator, + &block, + previous_block, + invalid_payloads, + metrics, + ) + .await + { + Ok(verdict) => verdict.validity(), + Err(e) => { + warn!( + %height, %round, %proposer, + "Skipping pending parts: transient engine validation error: {e:#}" + ); + metrics.inc_transient_validation_errors_count( + TransientValidationSource::StartedRoundPending, + ); + continue; + } + }; + + block.validity = validity; + + info!(%height, %round, %proposer, ?validity, "Added pending block to undecided"); + + // Atomically remove from pending and store as undecided. + // This ensures that if the process fails, the parts are not lost. + remove_pending_parts_and_store_undecided_block(store, parts, block).await?; } Ok(()) } -/// Sends all undecided blocks for the given height and round to the execution -/// client, ensuring the client has the corresponding payloads locally. -/// This is important in two scenarios: -/// 1. when validating newly created undecided blocks reconstructed from proposal -/// parts. -/// 2. when re-validating undecided blocks after a crash or restart. +/// Re-sends every undecided block for the given height and round to the +/// execution client. This is the **restart recovery** path: after a crash, +/// the EL may have lost the in-memory tree state for these blocks and must +/// be re-fed them so subsequent `forkchoice_updated` calls can succeed. +/// +/// The stored row's `validity` was set by `process_pending_proposal_parts` +/// at insertion time, so this function does not need to *establish* a verdict +/// — it only refreshes the EL's view and reconciles any change. /// -/// The second case addresses the EL "amnesia" issue, where the execution client may -/// have forgotten previously validated payloads that were only stored in memory and -/// lost after a restart. +/// On a transient engine error (`Err`), the existing verdict is kept: the row +/// already reflects a real engine call, so a momentary engine outage must not +/// flip it. /// -/// After each block is validated, the engine's verdict is persisted back to the -/// undecided blocks table. +/// A row that breaks the binding rules is invalid whatever the engine answers, +/// and the engine answers `Valid` for a genuine block that sits at another +/// height. Such a row never reaches the engine, so the engine cannot revive it +/// and its verdict is not read back as an engine verdict change. async fn validate_undecided_blocks( height: Height, round: Round, + previous_block: Option<&ExecutionBlock>, undecided_blocks: &impl UndecidedBlocksRepository, payload_validator: &impl PayloadValidator, invalid_payloads: &impl InvalidPayloadsRepository, @@ -252,49 +380,89 @@ async fn validate_undecided_blocks( ) })?; - // Holds all blocks that were validated (either valid or invalid) let mut validated_blocks = Vec::with_capacity(blocks.len()); for mut block in blocks { - let block_hash = block.block_hash(); + let block_hash = block.self_reported_block_hash(); + let existing_validity = block.validity; - info!(%height, %round, %block_hash, "Validating undecided block"); + info!(%height, %round, %block_hash, ?existing_validity, "Re-validating undecided block"); - let validity = match validate_consensus_block( - payload_validator, - &block, - invalid_payloads, - metrics, - ) - .await + if let Err(error) = + check_payload_binding(&block.execution_payload, block.height, previous_block) { - Ok(validity) => validity, - Err(e) => { - error!(%height, %round, %block_hash, "Failed to validate undecided block, marking Invalid: {e}"); - Validity::Invalid + warn!( + %height, %round, %block_hash, ?existing_validity, reason = %error, + "Undecided block is not bound to its place in the chain; keeping it invalid", + ); + + // A row inserted by this binary already carries the verdict and its + // forensic record. One that still reads `Valid` predates the rules. + if existing_validity.is_valid() { + block.validity = Validity::Invalid; + + metrics.inc_invalid_payloads_count(error.invalid_payload_source()); + + persist_invalid_payload_best_effort( + invalid_payloads, + InvalidPayload::new_from_block(&block, &error.to_string()), + height, + round, + block.proposer, + ) + .await; + + undecided_blocks + .store_undecided_block(block.clone()) + .await + .wrap_err_with(|| { + format!( + "Failed to persist the binding verdict for undecided block \ + {block_hash} at height={height} round={round}" + ) + })?; } - }; - - block.validity = validity; - // Persist the engine's verdict before returning the block to consensus. - undecided_blocks - .store_undecided_block(block.clone()) - .await - .wrap_err_with(|| { - format!( - "Failed to persist validated undecided block {block_hash} \ - at height={height} round={round}" - ) - })?; + validated_blocks.push(block); + continue; + } - validated_blocks.push(block); + match validate_consensus_block(payload_validator, &block, invalid_payloads, metrics).await { + Ok(new_validity) => { + if new_validity != existing_validity { + warn!( + %height, %round, %block_hash, + from = ?existing_validity, to = ?new_validity, + "Engine verdict changed on re-validation", + ); + block.validity = new_validity; + undecided_blocks + .store_undecided_block(block.clone()) + .await + .wrap_err_with(|| { + format!( + "Failed to persist re-validated undecided block {block_hash} \ + at height={height} round={round}" + ) + })?; + } + } + Err(e) => { + warn!( + %height, %round, %block_hash, ?existing_validity, + "Re-validation failed transiently; keeping existing verdict: {e:#}", + ); + metrics.inc_transient_validation_errors_count( + TransientValidationSource::StartedRoundRevalidation, + ); + } + } - if !validity.is_valid() { - // It is possible that we had multiple blocks before restart, - // and one or more of them are invalid. We continue to the next block. + if !block.validity.is_valid() { warn!(%height, %round, %block_hash, "Undecided block is invalid"); } + + validated_blocks.push(block); } Ok(validated_blocks) @@ -309,7 +477,7 @@ async fn remove_pending_parts_and_store_undecided_block( ) -> eyre::Result<()> { let height = block.height; let round = block.round; - let block_hash = block.block_hash(); + let block_hash = block.self_reported_block_hash(); store .remove_pending_parts_and_store_undecided_block(parts, block) @@ -330,6 +498,8 @@ mod tests { use crate::store::repositories::mocks::{ MockInvalidPayloadsRepository, MockUndecidedBlocksRepository, }; + use alloy_rpc_types_engine::{PayloadStatus, PayloadStatusEnum}; + use arc_eth_engine::engine::{MockEngineAPI, MockEthereumAPI}; use std::sync::{Arc, Mutex}; @@ -344,18 +514,25 @@ mod tests { use tempfile::tempdir; use crate::handlers::test_utils::signed_parts_without_data; + use crate::proposal_parts::make_proposal_parts; fn create_dummy_block(height: Height, round: Round, seed: u8) -> ConsensusBlock { let bytes = [seed; 1024]; let mut u = Unstructured::new(&bytes); + let proposer = Address::arbitrary(&mut u).unwrap(); + + // The block number carries the height, so the block is bound to it. + let mut execution_payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + execution_payload.payload_inner.payload_inner.block_number = height.as_u64(); + ConsensusBlock { height, round, valid_round: Round::Nil, - proposer: Address::arbitrary(&mut u).unwrap(), + proposer, validity: Validity::Valid, - execution_payload: ExecutionPayloadV3::arbitrary(&mut u).unwrap(), + execution_payload, signature: None, } } @@ -373,8 +550,115 @@ mod tests { (store, dir) } + #[test] + fn record_missed_rounds_counts_each_skipped_round() { + // Advancing from a fresh height (previous round Nil) straight to round 3 + // means rounds 0, 1 and 2 were started but never decided: three missed + // rounds, each attributed to that round's round-robin proposer. The round + // that actually started (3) is not itself a miss. + let keys: Vec = (0..4) + .map(|_| PrivateKey::generate(rand::rngs::OsRng)) + .collect(); + let validator_set = + ValidatorSet::new(keys.iter().map(|k| Validator::new(k.public_key(), 1))); + let selector = RoundRobin; + let height = Height::new(1); + let metrics = AppMetrics::default(); + + record_missed_rounds( + &metrics, + &selector, + &validator_set, + height, + Round::Nil, + Round::new(3), + ); + + // At height 1 the round-robin index is `round % 4`, so rounds 0..=3 map to + // four distinct proposers; assert exactly one increment per skipped round. + for missed in [Round::new(0), Round::new(1), Round::new(2)] { + let proposer = selector + .select_proposer(&validator_set, height, missed) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(proposer), + 1, + "round {missed} should be counted once against its proposer", + ); + } + + let started = selector + .select_proposer(&validator_set, height, Round::new(3)) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(started), + 0, + "the round that actually started is not a missed round", + ); + } + + #[test] + fn record_missed_rounds_starting_round_zero_is_noop() { + // Starting round 0 of a fresh height (previous round Nil) skips nothing. + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator_set = ValidatorSet::new(vec![Validator::new(signing_key.public_key(), 1)]); + let selector = RoundRobin; + let height = Height::new(1); + let metrics = AppMetrics::default(); + + record_missed_rounds( + &metrics, + &selector, + &validator_set, + height, + Round::Nil, + Round::new(0), + ); + + let proposer = selector + .select_proposer(&validator_set, height, Round::new(0)) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(proposer), + 0, + "no round is skipped when starting at round 0", + ); + } + + #[test] + fn record_missed_rounds_same_round_is_noop() { + // prev_round == new_round exercises the `missed_round < new_round` loop + // boundary: re-entering the current round counts nothing. + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator_set = ValidatorSet::new(vec![Validator::new(signing_key.public_key(), 1)]); + let selector = RoundRobin; + let height = Height::new(1); + let metrics = AppMetrics::default(); + + record_missed_rounds( + &metrics, + &selector, + &validator_set, + height, + Round::new(2), + Round::new(2), + ); + + let proposer = selector + .select_proposer(&validator_set, height, Round::new(2)) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(proposer), + 0, + "re-entering the same round is not a miss", + ); + } + #[tokio::test] async fn validate_undecided_blocks_all_valid() { + // Stored rows already reflect a real Valid verdict from + // process_pending_proposal_parts, so re-validation that returns Valid + // does not write anything back. let height = Height::new(1); let round = Round::new(0); @@ -386,11 +670,7 @@ mod tests { undecided .expect_get_by_round() .returning(move |_, _| Ok(blocks.clone())); - undecided - .expect_store_undecided_block() - .times(2) - .withf(|b| b.validity == Validity::Valid) - .returning(|_| Ok(())); + undecided.expect_store_undecided_block().times(0); let mut validator = MockPayloadValidator::new(); validator @@ -402,10 +682,11 @@ mod tests { invalid.expect_append().times(0); let metrics = AppMetrics::default(); - let result = - validate_undecided_blocks(height, round, &undecided, &validator, &invalid, &metrics) - .await - .expect("should succeed"); + let result = validate_undecided_blocks( + height, round, None, &undecided, &validator, &invalid, &metrics, + ) + .await + .expect("should succeed"); assert_eq!(result.len(), 2); assert!(result.iter().all(|b| b.validity == Validity::Valid)); @@ -413,7 +694,10 @@ mod tests { } #[tokio::test] - async fn validate_undecided_blocks_mixed_validity() { + async fn validate_undecided_blocks_engine_flips_verdict_persists_change() { + // If the engine's verdict on re-validation differs from the stored + // row's validity, the new verdict is persisted. Verdicts that match + // are not re-written. let height = Height::new(1); let round = Round::new(0); @@ -426,12 +710,12 @@ mod tests { .expect_get_by_round() .returning(move |_, _| Ok(blocks.clone())); - // Record the validity of every block persisted, in call order. + // Only block2 (flipped Valid -> Invalid) is persisted; block1 (Valid -> Valid) is not. let persisted = Arc::new(Mutex::new(Vec::::new())); let persisted_clone = Arc::clone(&persisted); undecided .expect_store_undecided_block() - .times(2) + .times(1) .returning(move |b| { persisted_clone.lock().unwrap().push(b.validity); Ok(()) @@ -457,18 +741,19 @@ mod tests { invalid.expect_append().times(1).returning(|_| Ok(())); let metrics = AppMetrics::default(); - let result = - validate_undecided_blocks(height, round, &undecided, &validator, &invalid, &metrics) - .await - .expect("should succeed"); + let result = validate_undecided_blocks( + height, round, None, &undecided, &validator, &invalid, &metrics, + ) + .await + .expect("should succeed"); assert_eq!(result.len(), 2); assert_eq!(result[0].validity, Validity::Valid); assert_eq!(result[1].validity, Validity::Invalid); assert_eq!( *persisted.lock().unwrap(), - vec![Validity::Valid, Validity::Invalid], - "persisted validity should match engine verdict, not the placeholder" + vec![Validity::Invalid], + "only the block whose verdict changed should be persisted", ); assert_eq!(metrics.get_invalid_payloads_count(), 1); } @@ -485,10 +770,11 @@ mod tests { let invalid = MockInvalidPayloadsRepository::new(); let metrics = AppMetrics::default(); - let result = - validate_undecided_blocks(height, round, &undecided, &validator, &invalid, &metrics) - .await - .expect("should succeed"); + let result = validate_undecided_blocks( + height, round, None, &undecided, &validator, &invalid, &metrics, + ) + .await + .expect("should succeed"); assert!(result.is_empty()); assert_eq!(metrics.get_invalid_payloads_count(), 0); @@ -508,10 +794,11 @@ mod tests { let invalid = MockInvalidPayloadsRepository::new(); let metrics = AppMetrics::default(); - let err = - validate_undecided_blocks(height, round, &undecided, &validator, &invalid, &metrics) - .await - .expect_err("should propagate repository error"); + let err = validate_undecided_blocks( + height, round, None, &undecided, &validator, &invalid, &metrics, + ) + .await + .expect_err("should propagate repository error"); assert!( err.to_string().contains("Failed to fetch undecided blocks"), @@ -521,12 +808,11 @@ mod tests { } #[tokio::test] - async fn validate_undecided_blocks_validation_error_marks_invalid() { - // When the engine call fails (transport error, SYNCING/ACCEPTED, etc.) - // we treat the block as `Invalid` for the current round so the placeholder - // `Valid` written by `process_pending_proposal_parts` does not leak. - // Malachite's `FullProposalKeeper::handle_validity_change` rejects any - // subsequent `Valid -> Invalid` flip on the same WAL entry. + async fn validate_undecided_blocks_validation_error_keeps_existing_verdict() { + // When the engine call fails transiently during re-validation we keep + // the stored row's existing verdict. The row already reflects a real + // engine verdict set at insertion time by process_pending_proposal_parts, + // so a momentary outage must not flip it. let height = Height::new(1); let round = Round::new(0); @@ -539,17 +825,9 @@ mod tests { .expect_get_by_round() .returning(move |_, _| Ok(blocks.clone())); - // Both blocks must be persisted: the errored block with `Invalid`, - // the successful one with `Valid`. Order matches the input order. - let persisted = Arc::new(Mutex::new(Vec::::new())); - let persisted_clone = Arc::clone(&persisted); - undecided - .expect_store_undecided_block() - .times(2) - .returning(move |b| { - persisted_clone.lock().unwrap().push(b.validity); - Ok(()) - }); + // Neither block triggers a store: the errored block keeps its verdict + // (no write), the successful one re-validates to the same verdict (no write). + undecided.expect_store_undecided_block().times(0); let mut call_count = 0usize; let mut validator = MockPayloadValidator::new(); @@ -570,22 +848,26 @@ mod tests { invalid.expect_append().times(0); let metrics = AppMetrics::default(); - let result = - validate_undecided_blocks(height, round, &undecided, &validator, &invalid, &metrics) - .await - .expect("should succeed despite one block erroring"); + let result = validate_undecided_blocks( + height, round, None, &undecided, &validator, &invalid, &metrics, + ) + .await + .expect("should succeed despite one block erroring"); assert_eq!(result.len(), 2, "both blocks should be returned"); - assert_eq!(result[0].validity, Validity::Invalid); - assert_eq!(result[1].validity, Validity::Valid); assert_eq!( - *persisted.lock().unwrap(), - vec![Validity::Invalid, Validity::Valid], - "errored block must be persisted as Invalid, not left with placeholder Valid", + result[0].validity, + Validity::Valid, + "errored block keeps its existing Valid verdict — not flipped to Invalid", ); - // Engine failure is not counted as an engine rejection; the metric - // tracks `EngineReject` and `AssemblyFailure`, not transport errors. + assert_eq!(result[1].validity, Validity::Valid); assert_eq!(metrics.get_invalid_payloads_count(), 0); + assert_eq!( + metrics.get_transient_validation_errors_count_by_source( + TransientValidationSource::StartedRoundRevalidation + ), + 1, + ); } #[tokio::test] @@ -604,6 +886,12 @@ mod tests { let provider = ArcSigningProvider::Local(LocalSigningProvider::new(signing_key)); let metrics = AppMetrics::default(); + // Assembly fails before validation, so the engine is never called. + let mut payload_validator = MockPayloadValidator::new(); + payload_validator.expect_validate_payload().times(0); + let mut invalid_payloads = MockInvalidPayloadsRepository::new(); + invalid_payloads.expect_append().times(0); + process_pending_proposal_parts( &store, vec![parts], @@ -611,8 +899,11 @@ mod tests { round, &validator_set, &selector, + &payload_validator, + &invalid_payloads, &provider, &metrics, + None, ) .await .expect("should handle assembly failure gracefully"); @@ -620,11 +911,576 @@ mod tests { assert_eq!(metrics.get_invalid_payloads_count(), 1); } + /// Builds a signed `ProposalParts` from a real `ConsensusBlock` whose + /// proposer matches the single-validator set used by these tests. + /// Returns the parts and the block hash so callers can query the store. + async fn signed_parts_for_single_validator( + height: Height, + round: Round, + signing_key: &PrivateKey, + seed: u8, + ) -> (ProposalParts, arc_consensus_types::BlockHash) { + signed_parts_with_payload_height(height, height.as_u64(), round, signing_key, seed).await + } + + /// Parts for `height` whose payload reports `payload_height` as its block + /// number. The two differ only in a test of the binding rules. + async fn signed_parts_with_payload_height( + height: Height, + payload_height: u64, + round: Round, + signing_key: &PrivateKey, + seed: u8, + ) -> (ProposalParts, arc_consensus_types::BlockHash) { + let proposer = Address::from_public_key(&signing_key.public_key()); + let bytes = [seed; 1024]; + let mut u = Unstructured::new(&bytes); + + let mut execution_payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + execution_payload.payload_inner.payload_inner.block_number = payload_height; + + let block = ConsensusBlock { + height, + round, + valid_round: Round::Nil, + proposer, + validity: Validity::Valid, + execution_payload, + signature: None, + }; + let block_hash = block.self_reported_block_hash(); + + let provider = LocalSigningProvider::new(signing_key.clone()); + let (raw_parts, _sig) = make_proposal_parts(&provider, &block).await.unwrap(); + (ProposalParts::new(raw_parts).unwrap(), block_hash) + } + + #[tokio::test] + async fn process_pending_proposal_parts_inserts_valid_block_and_clears_pending() { + let (store, _dir) = test_store().await; + + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator = Validator::new(signing_key.public_key(), 1); + let validator_set = ValidatorSet::new(vec![validator]); + + let height = Height::new(1); + let round = Round::new(0); + let (parts, block_hash) = + signed_parts_for_single_validator(height, round, &signing_key, 0xAA).await; + + store + .store_pending_proposal_parts(parts.clone(), 100, height) + .await + .unwrap(); + + let selector = RoundRobin; + let provider = ArcSigningProvider::Local(LocalSigningProvider::new(signing_key)); + let metrics = AppMetrics::default(); + + let mut payload_validator = MockPayloadValidator::new(); + payload_validator + .expect_validate_payload() + .times(1) + .returning(|_| Ok(PayloadValidationResult::Valid)); + let mut invalid_payloads = MockInvalidPayloadsRepository::new(); + invalid_payloads.expect_append().times(0); + + process_pending_proposal_parts( + &store, + vec![parts], + height, + round, + &validator_set, + &selector, + &payload_validator, + &invalid_payloads, + &provider, + &metrics, + None, + ) + .await + .expect("validate-and-insert should succeed"); + + let stored = store + .get_undecided_block(height, round, block_hash) + .await + .unwrap() + .expect("undecided block should be stored"); + assert_eq!(stored.validity, Validity::Valid); + + let remaining = store + .get_pending_proposal_parts(height, round) + .await + .unwrap(); + assert!(remaining.is_empty(), "pending parts must be removed"); + assert_eq!(metrics.get_invalid_payloads_count(), 0); + } + + /// The whole round-start sequence, which is where a rejection has to survive: + /// `process_pending_proposal_parts` writes the binding verdict, and + /// `validate_undecided_blocks` reads the same row back two statements later. + /// The engine answers `VALID` here, because the payload is a genuine block + /// that sits at another height. Consensus must still receive `Invalid`. + #[tokio::test] + async fn fetch_and_process_pending_proposals_keeps_an_unbound_payload_invalid() { + let (store, _dir) = test_store().await; + + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator = Validator::new(signing_key.public_key(), 1); + let validator_set = ValidatorSet::new(vec![validator]); + + let height = Height::new(11); + let round = Round::new(0); + let (parts, _block_hash) = + signed_parts_with_payload_height(height, 5, round, &signing_key, 0xAA).await; + + store + .store_pending_proposal_parts(parts, 100, height) + .await + .unwrap(); + + let mut engine_mock = MockEngineAPI::new(); + engine_mock.expect_new_payload().returning(|_, _, _, _| { + Ok(PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: None, + }) + }); + let engine = Engine::new(Box::new(engine_mock), Box::new(MockEthereumAPI::new())); + + let selector = RoundRobin; + let provider = ArcSigningProvider::Local(LocalSigningProvider::new(signing_key)); + let metrics = AppMetrics::default(); + + let proposals = fetch_and_process_pending_proposals( + height, + round, + &validator_set, + &selector, + &store, + &engine, + &provider, + &metrics, + None, + ) + .await + .expect("round start should succeed"); + + assert_eq!(proposals.len(), 1); + assert_eq!( + proposals[0].validity, + Validity::Invalid, + "the engine verdict must not revive a binding rejection", + ); + } + + /// A proposal whose parts arrived before its round started (buffered as + /// pending) is still prevoted nil when its timestamp is too far ahead of local + /// time, while the stored block keeps its execution-only `Valid` verdict — so it + /// stays adoptable via a commit certificate. Mirrors the live-arrival path and + /// closes the early-arrival bypass. + #[tokio::test] + async fn fetch_and_process_pending_proposals_votes_nil_on_future_timestamp_but_stores_valid() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let (store, _dir) = test_store().await; + + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator = Validator::new(signing_key.public_key(), 1); + let validator_set = ValidatorSet::new(vec![validator]); + + let height = Height::new(11); + let round = Round::new(0); + + // A block bound to the height (execution-valid) but stamped far ahead of + // local time, streamed as pending parts. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let proposer = Address::from_public_key(&signing_key.public_key()); + let mut u = Unstructured::new(&[0xAB; 1024]); + let mut execution_payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + execution_payload.payload_inner.payload_inner.block_number = height.as_u64(); + execution_payload.payload_inner.payload_inner.timestamp = now + 3600; + let block = ConsensusBlock { + height, + round, + valid_round: Round::Nil, + proposer, + validity: Validity::Valid, + execution_payload, + signature: None, + }; + let provider = ArcSigningProvider::Local(LocalSigningProvider::new(signing_key.clone())); + let (raw_parts, _sig) = make_proposal_parts(&provider, &block).await.unwrap(); + let parts = ProposalParts::new(raw_parts).unwrap(); + + store + .store_pending_proposal_parts(parts, 100, height) + .await + .unwrap(); + + let mut engine_mock = MockEngineAPI::new(); + engine_mock.expect_new_payload().returning(|_, _, _, _| { + Ok(PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: None, + }) + }); + let engine = Engine::new(Box::new(engine_mock), Box::new(MockEthereumAPI::new())); + + let selector = RoundRobin; + let metrics = AppMetrics::default(); + + let proposals = fetch_and_process_pending_proposals( + height, + round, + &validator_set, + &selector, + &store, + &engine, + &provider, + &metrics, + None, + ) + .await + .expect("round start should succeed"); + + assert_eq!(proposals.len(), 1); + assert_eq!( + proposals[0].validity, + Validity::Invalid, + "a too-far-future pending proposal must be prevoted nil", + ); + + let stored = store.get_by_round(height, round).await.unwrap(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].validity, + Validity::Valid, + "the stored block keeps its execution-only Valid verdict", + ); + } + + /// A row that predates the rules reads `Valid` and breaks them. The engine + /// answers `VALID`, and the verdict is still corrected and persisted. + #[tokio::test] + async fn validate_undecided_blocks_corrects_a_stored_valid_unbound_row() { + let height = Height::new(11); + let round = Round::new(0); + + let mut block = create_dummy_block(height, round, 0xCC); + block + .execution_payload + .payload_inner + .payload_inner + .block_number = 5; + + let mut undecided = MockUndecidedBlocksRepository::new(); + undecided + .expect_get_by_round() + .return_once(move |_, _| Ok(vec![block.clone()])); + undecided + .expect_store_undecided_block() + .times(1) + .withf(|b: &ConsensusBlock| b.validity == Validity::Invalid) + .returning(|_| Ok(())); + + let mut validator = MockPayloadValidator::new(); + validator.expect_validate_payload().times(0); + + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid + .expect_append() + .times(1) + .withf(|ip: &InvalidPayload| ip.reason.contains("does not match consensus height")) + .returning(|_| Ok(())); + + let metrics = AppMetrics::default(); + let blocks = validate_undecided_blocks( + height, round, None, &undecided, &validator, &invalid, &metrics, + ) + .await + .expect("should succeed"); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].validity, Validity::Invalid); + assert_eq!(metrics.get_invalid_payloads_count(), 1); + } + + /// The parent rule through the whole round-start sequence. This is the branch + /// where `previous_block` threading into the re-validation step matters. + #[tokio::test] + async fn fetch_and_process_pending_proposals_keeps_a_parent_mismatch_invalid() { + let (store, _dir) = test_store().await; + + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator = Validator::new(signing_key.public_key(), 1); + let validator_set = ValidatorSet::new(vec![validator]); + + let height = Height::new(11); + let round = Round::new(0); + // Bound to the height, so only the parent rule can reject it. + let (parts, _block_hash) = + signed_parts_with_payload_height(height, height.as_u64(), round, &signing_key, 0xDD) + .await; + + store + .store_pending_proposal_parts(parts, 100, height) + .await + .unwrap(); + + let mut engine_mock = MockEngineAPI::new(); + engine_mock.expect_new_payload().times(0); + let engine = Engine::new(Box::new(engine_mock), Box::new(MockEthereumAPI::new())); + + // The immediate predecessor, and not the block the payload extends. + let previous_block = ExecutionBlock { + block_hash: arc_consensus_types::B256::repeat_byte(0xAB), + block_number: 10, + parent_hash: arc_consensus_types::B256::ZERO, + timestamp: 0, + }; + + let selector = RoundRobin; + let provider = ArcSigningProvider::Local(LocalSigningProvider::new(signing_key)); + let metrics = AppMetrics::default(); + + let proposals = fetch_and_process_pending_proposals( + height, + round, + &validator_set, + &selector, + &store, + &engine, + &provider, + &metrics, + Some(&previous_block), + ) + .await + .expect("round start should succeed"); + + assert_eq!(proposals.len(), 1); + assert_eq!( + proposals[0].validity, + Validity::Invalid, + "the engine verdict must not revive a parent-rule rejection", + ); + assert_eq!( + metrics.get_invalid_payloads_count_by_source(InvalidPayloadSource::PayloadParent), + 1, + "the parent rule must be the rule that rejected it", + ); + } + + /// Pending parts whose payload belongs at another height: the row lands as + /// `Invalid` and the engine never sees the payload. + #[tokio::test] + async fn process_pending_proposal_parts_rejects_a_payload_from_another_height() { + let (store, _dir) = test_store().await; + + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator = Validator::new(signing_key.public_key(), 1); + let validator_set = ValidatorSet::new(vec![validator]); + + let height = Height::new(11); + let round = Round::new(0); + let (parts, block_hash) = + signed_parts_with_payload_height(height, 5, round, &signing_key, 0xAA).await; + + store + .store_pending_proposal_parts(parts.clone(), 100, height) + .await + .unwrap(); + + let selector = RoundRobin; + let provider = ArcSigningProvider::Local(LocalSigningProvider::new(signing_key)); + let metrics = AppMetrics::default(); + + let mut payload_validator = MockPayloadValidator::new(); + payload_validator.expect_validate_payload().times(0); + + let mut invalid_payloads = MockInvalidPayloadsRepository::new(); + invalid_payloads + .expect_append() + .times(1) + .withf(|ip: &InvalidPayload| ip.reason.contains("does not match consensus height")) + .returning(|_| Ok(())); + + process_pending_proposal_parts( + &store, + vec![parts], + height, + round, + &validator_set, + &selector, + &payload_validator, + &invalid_payloads, + &provider, + &metrics, + None, + ) + .await + .expect("a binding error is a verdict, not a failure"); + + let stored = store + .get_undecided_block(height, round, block_hash) + .await + .unwrap() + .expect("undecided block should be stored"); + assert_eq!(stored.validity, Validity::Invalid); + assert_eq!(metrics.get_invalid_payloads_count(), 1); + } + + #[tokio::test] + async fn process_pending_proposal_parts_inserts_invalid_block_and_clears_pending() { + let (store, _dir) = test_store().await; + + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator = Validator::new(signing_key.public_key(), 1); + let validator_set = ValidatorSet::new(vec![validator]); + + let height = Height::new(1); + let round = Round::new(0); + let (parts, block_hash) = + signed_parts_for_single_validator(height, round, &signing_key, 0xBB).await; + + store + .store_pending_proposal_parts(parts.clone(), 100, height) + .await + .unwrap(); + + let selector = RoundRobin; + let provider = ArcSigningProvider::Local(LocalSigningProvider::new(signing_key)); + let metrics = AppMetrics::default(); + + let mut payload_validator = MockPayloadValidator::new(); + payload_validator + .expect_validate_payload() + .times(1) + .returning(|_| { + Ok(PayloadValidationResult::Invalid { + reason: "engine rejected".into(), + }) + }); + let mut invalid_payloads = MockInvalidPayloadsRepository::new(); + invalid_payloads + .expect_append() + .times(1) + .returning(|_| Ok(())); + + process_pending_proposal_parts( + &store, + vec![parts], + height, + round, + &validator_set, + &selector, + &payload_validator, + &invalid_payloads, + &provider, + &metrics, + None, + ) + .await + .expect("validate-and-insert should succeed with Invalid verdict"); + + let stored = store + .get_undecided_block(height, round, block_hash) + .await + .unwrap() + .expect("undecided block should be stored"); + assert_eq!(stored.validity, Validity::Invalid); + + let remaining = store + .get_pending_proposal_parts(height, round) + .await + .unwrap(); + assert!(remaining.is_empty(), "pending parts must be removed"); + assert_eq!(metrics.get_invalid_payloads_count(), 1); + } + + #[tokio::test] + async fn process_pending_proposal_parts_skips_insert_on_transient_engine_error() { + let (store, _dir) = test_store().await; + + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator = Validator::new(signing_key.public_key(), 1); + let validator_set = ValidatorSet::new(vec![validator]); + + let height = Height::new(1); + let round = Round::new(0); + let (parts, block_hash) = + signed_parts_for_single_validator(height, round, &signing_key, 0xCC).await; + + store + .store_pending_proposal_parts(parts.clone(), 100, height) + .await + .unwrap(); + + let selector = RoundRobin; + let provider = ArcSigningProvider::Local(LocalSigningProvider::new(signing_key)); + let metrics = AppMetrics::default(); + + let mut payload_validator = MockPayloadValidator::new(); + payload_validator + .expect_validate_payload() + .times(1) + .returning(|_| Err(eyre::eyre!("engine unreachable"))); + let mut invalid_payloads = MockInvalidPayloadsRepository::new(); + invalid_payloads.expect_append().times(0); + + process_pending_proposal_parts( + &store, + vec![parts], + height, + round, + &validator_set, + &selector, + &payload_validator, + &invalid_payloads, + &provider, + &metrics, + None, + ) + .await + .expect("transient engine error must not fail the handler"); + + let stored = store + .get_undecided_block(height, round, block_hash) + .await + .unwrap(); + assert!( + stored.is_none(), + "no undecided row should be inserted when the engine errors", + ); + + let remaining = store + .get_pending_proposal_parts(height, round) + .await + .unwrap(); + assert_eq!( + remaining.len(), + 1, + "pending parts must stay in place for cleanup on commit", + ); + assert_eq!(metrics.get_invalid_payloads_count(), 0); + assert_eq!( + metrics.get_transient_validation_errors_count_by_source( + TransientValidationSource::StartedRoundPending + ), + 1, + ); + } + #[tokio::test] async fn validate_undecided_blocks_propagates_persist_error() { let height = Height::new(1); let round = Round::new(0); + // Stored block is Valid; re-validation returns Invalid (verdict + // change) so persist is attempted and fails — the error must propagate. let block = create_dummy_block(height, round, 0x33); let blocks = vec![block]; @@ -638,24 +1494,27 @@ mod tests { .returning(|_| Err(std::io::Error::other("disk full"))); let mut validator = MockPayloadValidator::new(); - validator - .expect_validate_payload() - .times(1) - .returning(|_| Ok(PayloadValidationResult::Valid)); + validator.expect_validate_payload().times(1).returning(|_| { + Ok(PayloadValidationResult::Invalid { + reason: "engine rejected".into(), + }) + }); - let invalid = MockInvalidPayloadsRepository::new(); + let mut invalid = MockInvalidPayloadsRepository::new(); + invalid.expect_append().times(1).returning(|_| Ok(())); let metrics = AppMetrics::default(); - let err = - validate_undecided_blocks(height, round, &undecided, &validator, &invalid, &metrics) - .await - .expect_err("persist error should propagate"); + let err = validate_undecided_blocks( + height, round, None, &undecided, &validator, &invalid, &metrics, + ) + .await + .expect_err("persist error should propagate"); assert!( err.to_string() - .contains("Failed to persist validated undecided block"), + .contains("Failed to persist re-validated undecided block"), "error should describe the persist failure, got: {err}", ); - assert_eq!(metrics.get_invalid_payloads_count(), 0); + assert_eq!(metrics.get_invalid_payloads_count(), 1); } } diff --git a/crates/malachite-app/src/handlers/test_utils.rs b/crates/malachite-app/src/handlers/test_utils.rs index 3f296e6c..907da6cd 100644 --- a/crates/malachite-app/src/handlers/test_utils.rs +++ b/crates/malachite-app/src/handlers/test_utils.rs @@ -18,6 +18,8 @@ use sha3::Digest; +use malachitebft_app_channel::app::streaming::{StreamContent, StreamId, StreamMessage}; + use arc_consensus_types::{ Address, Height, ProposalFin, ProposalInit, ProposalPart, ProposalParts, Round, }; @@ -50,3 +52,39 @@ pub(super) async fn signed_parts_without_data( ]) .unwrap() } + +/// Same data-less proposal as [`signed_parts_without_data`], but emitted as the +/// stream of [`StreamMessage`]s a peer would gossip, ready to feed through +/// `on_received_proposal_part`. The completed stream assembles to zero bytes, so +/// `assemble_block_from_parts` fails — exercising the assembly-failure path. +pub(super) async fn signed_stream_without_data( + stream_id: StreamId, + height: Height, + round: Round, + signing_key: &PrivateKey, +) -> Vec> { + let proposer = Address::from_public_key(&signing_key.public_key()); + let init = ProposalInit::new(height, round, Round::Nil, proposer); + + let mut hasher = sha3::Keccak256::new(); + hasher.update(height.as_u64().to_be_bytes()); + hasher.update(round.as_i64().to_be_bytes()); + let hash = hasher.finalize().to_vec(); + + let provider = LocalSigningProvider::new(signing_key.clone()); + let signature = provider.sign_bytes(&hash).await.unwrap(); + + vec![ + StreamMessage::new( + stream_id.clone(), + 0, + StreamContent::Data(ProposalPart::Init(init)), + ), + StreamMessage::new( + stream_id.clone(), + 1, + StreamContent::Data(ProposalPart::Fin(ProposalFin::new(signature))), + ), + StreamMessage::new(stream_id, 2, StreamContent::Fin), + ] +} diff --git a/crates/malachite-app/src/main.rs b/crates/malachite-app/src/main.rs index d69e8b28..52179ec8 100644 --- a/crates/malachite-app/src/main.rs +++ b/crates/malachite-app/src/main.rs @@ -20,9 +20,11 @@ /// Approximately 33 hours at 0.5 s/block (237600 × 0.5 s ≈ 33 h). const PRESETS_PRUNE_CERTIFICATES_DISTANCE: u64 = 237_600; +use std::net::{IpAddr, SocketAddr}; + use bytesize::ByteSize; use eyre::{eyre, Result}; -use tracing::{info, trace}; +use tracing::{info, trace, warn}; use arc_consensus_types::{ Config, ExecutionConfig, Height, MetricsConfig, PruningConfig, RpcConfig, RuntimeConfig, @@ -173,9 +175,18 @@ fn build_config_from_cli(cmd: &StartCmd, logging: config::LoggingConfig) -> Resu enabled: cmd.rpc_addr.is_some(), listen_addr: cmd .rpc_addr - .unwrap_or_else(|| "0.0.0.0:31000".parse().expect("valid socket address")), + .unwrap_or_else(|| "127.0.0.1:31000".parse().expect("valid socket address")), + admin: cmd.rpc_admin, }; + if rpc.enabled && rpc.admin && admin_bind_is_routable(rpc.listen_addr) { + warn!( + listen_addr = %rpc.listen_addr, + "admin RPC enabled on a routable address; ensure this interface is \ + isolated at the network layer (firewall / NetworkPolicy / allowlist)" + ); + } + let certificates_distance = if cmd.full || cmd.minimal { PRESETS_PRUNE_CERTIFICATES_DISTANCE } else { @@ -191,6 +202,9 @@ fn build_config_from_cli(cmd: &StartCmd, logging: config::LoggingConfig) -> Resu persistence_backpressure_threshold: cmd.execution_persistence_backpressure_threshold, }; + #[cfg(feature = "byzantine")] + let byzantine = cmd.byzantine.clone(); + Ok(Config { moniker: cmd.get_moniker(), logging, @@ -202,9 +216,31 @@ fn build_config_from_cli(cmd: &StartCmd, logging: config::LoggingConfig) -> Resu rpc, execution, signing: build_signing_config(cmd)?, + #[cfg(feature = "byzantine")] + byzantine, }) } +/// Whether an admin-enabled RPC server bound to `addr` would be reachable from +/// outside the host. Loopback, private (10/8, 172.16/12, 192.168/16) and +/// link-local addresses are treated as local; an unspecified address (`0.0.0.0` +/// / `::`) binds every interface and is always routable. +fn admin_bind_is_routable(addr: SocketAddr) -> bool { + let ip = addr.ip(); + if ip.is_unspecified() { + return true; + } + let is_local = match ip { + IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(), + IpAddr::V6(v6) => { + v6.is_loopback() + || (v6.segments()[0] & 0xfe00) == 0xfc00 // unique-local (fc00::/7) + || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local (fe80::/10) + } + }; + !is_local +} + fn start(args: &Args, cmd: &StartCmd, logging: config::LoggingConfig) -> Result<()> { // Validate command options before proceeding cmd.validate() @@ -213,6 +249,14 @@ fn start(args: &Args, cmd: &StartCmd, logging: config::LoggingConfig) -> Result< // Build configuration from CLI arguments let config = build_config_from_cli(cmd, logging)?; + // Validate the assembled config. `StartCmd::validate` only checks CLI-arg + // shape; `Config::validate` is where `ByzantineConfig::validate` is reached, + // so without this call an ad-hoc `--byzantine=` would bypass + // semantic checks (mutually-exclusive triggers, malformed ranges). + config + .validate() + .map_err(|error| eyre!("Invalid configuration: {error}"))?; + let rt = runtime::build_runtime(config.runtime)?; info!( @@ -495,6 +539,27 @@ mod tests { } } + #[test] + fn admin_bind_is_routable_flags_only_reachable_addresses() { + let routable = |s: &str| admin_bind_is_routable(s.parse::().unwrap()); + + // Local / unreachable from outside the host: no warning. + assert!(!routable("127.0.0.1:31000")); + assert!(!routable("10.0.0.5:31000")); + assert!(!routable("172.16.1.1:31000")); + assert!(!routable("192.168.1.1:31000")); + assert!(!routable("169.254.0.1:31000")); + assert!(!routable("[::1]:31000")); + assert!(!routable("[fd00::1]:31000")); + assert!(!routable("[fe80::1]:31000")); + + // Unspecified binds every interface; public addresses are reachable. + assert!(routable("0.0.0.0:31000")); + assert!(routable("[::]:31000")); + assert!(routable("8.8.8.8:31000")); + assert!(routable("[2001:db8::1]:31000")); + } + #[test] fn build_signing_config_defaults_to_local() { let cmd = minimal_start_cmd(); @@ -680,8 +745,8 @@ mod tests { let config = build_config_from_cli(&cmd, logging).unwrap(); assert!(!config.rpc.enabled); - // Default address is still set even when disabled - assert_eq!(config.rpc.listen_addr.to_string(), "0.0.0.0:31000"); + // Default address is still set even when disabled, and binds to loopback. + assert_eq!(config.rpc.listen_addr.to_string(), "127.0.0.1:31000"); } #[test] diff --git a/crates/malachite-app/src/metrics/app.rs b/crates/malachite-app/src/metrics/app.rs index c8eca570..4e7bf253 100644 --- a/crates/malachite-app/src/metrics/app.rs +++ b/crates/malachite-app/src/metrics/app.rs @@ -92,6 +92,18 @@ pub struct Inner { /// Time taken for each Engine API call engine_api_time: Family, + /// Time taken to serve RPC requests + rpc_request_time: Family, + + /// Number of app requests rejected because the app request channel was full + app_request_full_count: Family, + + /// Time app requests spend waiting in the app request channel + app_request_queue_time: Family, + + /// Time taken to process app requests + app_request_process_time: Family, + /// The number of times the consensus height has been restarted height_restart_count: Counter, @@ -102,6 +114,20 @@ pub struct Inner { /// labelled by source (engine reject, assembly failure, sync decode). invalid_payloads_count: Family, + /// Number of prevotes downgraded to nil because the proposer's header + /// timestamp was too far ahead of local time, labelled by the path that saw + /// the proposal (live arrival vs buffered/round-start). + clock_skew_nil_vote_count: Family, + + /// Number of payload validations that yielded no verdict (transient engine + /// error), labelled by source. + transient_validation_errors_count: Family, + + /// Number of times the node stopped because a payload was not bound to its + /// place in the chain, labelled by the path that found it. Any non-zero value + /// is an alert, and the label says where to start reading. + binding_halt_count: Family, + /// Number of pending proposal parts waiting to be processed at a future height or round pending_proposal_parts_count: Gauge, @@ -111,6 +137,9 @@ pub struct Inner { /// Number of blocks replayed from CL to EL during startup handshake handshake_replay_blocks: Gauge, + /// Number of consensus rounds that failed to decide before advancing to the next round + consensus_round_missed: Family, + /// Internal state recording the previous validator set. /// Useful field to manage validators' metrics. /// This field is only accessible internally, and is not a metrics itself. @@ -139,12 +168,26 @@ impl Inner { engine_api_time: Family::new_with_constructor(|| { Histogram::new(exponential_buckets_range(0.001, 2.0, 10)) }), + rpc_request_time: Family::new_with_constructor(|| { + Histogram::new(exponential_buckets_range(0.001, 2.0, 16)) + }), + app_request_full_count: Family::default(), + app_request_queue_time: Family::new_with_constructor(|| { + Histogram::new(exponential_buckets_range(0.001, 2.0, 16)) + }), + app_request_process_time: Family::new_with_constructor(|| { + Histogram::new(exponential_buckets_range(0.001, 2.0, 16)) + }), height_restart_count: Counter::default(), sync_fell_behind_count: Counter::default(), invalid_payloads_count: Family::default(), + clock_skew_nil_vote_count: Family::default(), + transient_validation_errors_count: Family::default(), + binding_halt_count: Family::default(), pending_proposal_parts_count: Gauge::default(), consensus_params: Family::default(), handshake_replay_blocks: Gauge::default(), + consensus_round_missed: Family::default(), } } } @@ -250,6 +293,30 @@ impl AppMetrics { metrics.engine_api_time.clone(), ); + registry.register( + "rpc_request_time", + "Time taken to serve RPC requests, in seconds", + metrics.rpc_request_time.clone(), + ); + + registry.register( + "app_request_full_count", + "Number of app requests rejected because the app request channel was full", + metrics.app_request_full_count.clone(), + ); + + registry.register( + "app_request_queue_time", + "Time app requests spend waiting in the app request channel, in seconds", + metrics.app_request_queue_time.clone(), + ); + + registry.register( + "app_request_process_time", + "Time taken to process app requests, in seconds", + metrics.app_request_process_time.clone(), + ); + registry.register( "height_restart_count", "The number of times the consensus height has been restarted", @@ -268,6 +335,24 @@ impl AppMetrics { metrics.invalid_payloads_count.clone(), ); + registry.register( + "clock_skew_nil_vote_count", + "Number of prevotes downgraded to nil for a too-far-future proposer timestamp, labelled by path", + metrics.clock_skew_nil_vote_count.clone(), + ); + + registry.register( + "binding_halt_count", + "Number of node halts caused by a payload not bound to its place in the chain, labelled by site", + metrics.binding_halt_count.clone(), + ); + + registry.register( + "transient_validation_errors_count", + "Number of transient (no-verdict) payload validation errors, labelled by source", + metrics.transient_validation_errors_count.clone(), + ); + registry.register( "pending_proposal_parts_count", "Number of pending proposal parts waiting to be processed at a future height or round", @@ -280,6 +365,12 @@ impl AppMetrics { metrics.handshake_replay_blocks.clone(), ); + registry.register( + "consensus_round_missed", + "Number of consensus rounds that failed to decide before advancing to the next round", + metrics.consensus_round_missed.clone(), + ); + // Register version info as a separate Info metric let version_info = Info::new(VersionInfoLabel { version: arc_version::SHORT_VERSION, @@ -419,6 +510,46 @@ impl AppMetrics { }) } + /// Start a timer for an RPC request. + /// + /// The returned guard will record the time taken for the request when dropped. + #[must_use] + pub fn start_rpc_request_timer(&self, endpoint: &'static str) -> MetricsGuard { + MetricsGuard::new(self.clone(), endpoint, |metrics, endpoint, elapsed| { + metrics + .rpc_request_time + .get_or_create(&RpcEndpointLabel::new(endpoint)) + .observe(elapsed.as_secs_f64()); + }) + } + + /// Increment the number of app requests rejected by a full app request channel. + pub fn inc_app_request_full_count(&self, request: &'static str) { + self.app_request_full_count + .get_or_create(&AppRequestLabel::new(request)) + .inc(); + } + + /// Observe the time an app request spent waiting in the app request channel. + pub fn observe_app_request_queue_time(&self, request: &'static str, seconds: f64) { + self.app_request_queue_time + .get_or_create(&AppRequestLabel::new(request)) + .observe(seconds); + } + + /// Start a timer for app request processing. + /// + /// The returned guard will record processing time when dropped. + #[must_use] + pub fn start_app_request_process_timer(&self, request: &'static str) -> MetricsGuard { + MetricsGuard::new(self.clone(), request, |metrics, request, elapsed| { + metrics + .app_request_process_time + .get_or_create(&AppRequestLabel::new(request)) + .observe(elapsed.as_secs_f64()); + }) + } + /// Increment the number of times the consensus height has been restarted pub fn inc_height_restart_count(&self) { self.height_restart_count.inc(); @@ -453,6 +584,63 @@ impl AppMetrics { .get() } + /// Increment the clock-skew nil-vote counter for the given path. + pub fn inc_clock_skew_nil_vote_count(&self, source: SkewNilVoteSource) { + self.clock_skew_nil_vote_count + .get_or_create(&SkewNilVoteSourceLabel::new(source)) + .inc(); + } + + /// Number of clock-skew nil-votes recorded for a specific path. + #[cfg(test)] + pub fn get_clock_skew_nil_vote_count_by_source(&self, source: SkewNilVoteSource) -> u64 { + self.clock_skew_nil_vote_count + .get_or_create(&SkewNilVoteSourceLabel::new(source)) + .get() + } + + /// Increment the transient-validation-error counter for the given source. + pub fn inc_transient_validation_errors_count(&self, source: TransientValidationSource) { + self.transient_validation_errors_count + .get_or_create(&TransientValidationSourceLabel::new(source)) + .inc(); + } + + /// Total number of transient validation errors across all sources. + #[cfg(test)] + pub fn get_transient_validation_errors_count(&self) -> u64 { + TransientValidationSource::ALL + .iter() + .map(|source| self.get_transient_validation_errors_count_by_source(*source)) + .sum() + } + + /// Number of transient validation errors recorded for a specific source. + #[cfg(test)] + pub fn get_transient_validation_errors_count_by_source( + &self, + source: TransientValidationSource, + ) -> u64 { + self.transient_validation_errors_count + .get_or_create(&TransientValidationSourceLabel::new(source)) + .get() + } + + /// Increment the binding-halt counter for the path that found the break. + pub fn inc_binding_halt_count(&self, site: BindingHaltSite) { + self.binding_halt_count + .get_or_create(&BindingHaltLabel::new(site)) + .inc(); + } + + /// Number of binding halts recorded at a specific site. + #[cfg(test)] + pub fn get_binding_halt_count(&self, site: BindingHaltSite) -> u64 { + self.binding_halt_count + .get_or_create(&BindingHaltLabel::new(site)) + .get() + } + /// Observe the number of pending proposal parts pub fn observe_pending_proposal_parts_count(&self, count: usize) { self.pending_proposal_parts_count.set(count as i64); @@ -468,6 +656,21 @@ impl AppMetrics { pub fn get_handshake_replay_blocks(&self) -> u64 { self.handshake_replay_blocks.get() } + + /// Record that a consensus round failed to decide before advancing. + pub fn inc_consensus_round_missed(&self, proposer: Address) { + self.consensus_round_missed + .get_or_create(&RoundMissedLabel::new(proposer)) + .inc(); + } + + /// Total number of missed consensus rounds since start. + #[cfg(test)] + pub fn get_consensus_round_missed_count(&self, proposer: Address) -> u64 { + self.consensus_round_missed + .get_or_create(&RoundMissedLabel::new(proposer)) + .get() + } } impl Default for AppMetrics { @@ -505,6 +708,19 @@ impl AddressLabel { } } +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct RoundMissedLabel { + proposer: AsLabelValue
, +} + +impl RoundMissedLabel { + fn new(proposer: Address) -> Self { + Self { + proposer: AsLabelValue(proposer), + } + } +} + #[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] struct ProcessMsgLabel { msg: &'static str, @@ -527,6 +743,28 @@ impl EngineApiLabel { } } +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct RpcEndpointLabel { + endpoint: &'static str, +} + +impl RpcEndpointLabel { + fn new(endpoint: &'static str) -> Self { + Self { endpoint } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct AppRequestLabel { + request: &'static str, +} + +impl AppRequestLabel { + fn new(request: &'static str) -> Self { + Self { request } + } +} + /// Source of an invalid-payload record, used to label the counter. #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum InvalidPayloadSource { @@ -537,18 +775,32 @@ pub enum InvalidPayloadSource { AssemblyFailure, /// SSZ decode of a value received via sync failed. SyncDecode, + /// The payload block number is not the consensus height. This is a property + /// of the payload alone, so the sender is answerable for it. + PayloadHeight, + /// The payload does not extend the block finalized at the previous height. + /// This one compares the payload against local state, so it also rises when + /// this node's view of that height is the one that is wrong. + PayloadParent, } impl InvalidPayloadSource { #[cfg(test)] - pub(super) const ALL: [InvalidPayloadSource; 3] = - [Self::EngineReject, Self::AssemblyFailure, Self::SyncDecode]; + pub(super) const ALL: [InvalidPayloadSource; 5] = [ + Self::EngineReject, + Self::AssemblyFailure, + Self::SyncDecode, + Self::PayloadHeight, + Self::PayloadParent, + ]; fn as_str(&self) -> &'static str { match self { Self::EngineReject => "engine_reject", Self::AssemblyFailure => "assembly_failure", Self::SyncDecode => "sync_decode", + Self::PayloadHeight => "payload_height", + Self::PayloadParent => "payload_parent", } } } @@ -566,6 +818,116 @@ impl InvalidPayloadSourceLabel { } } +/// The path on which a clock-skew nil-vote was recorded, used to label the +/// counter so isolated local drift can be told apart from a broader problem. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum SkewNilVoteSource { + /// A proposal that arrived live for the current round. + ReceivedProposal, + /// A proposal whose parts were buffered and re-offered at round start. + StartedRound, +} + +impl SkewNilVoteSource { + fn as_str(&self) -> &'static str { + match self { + Self::ReceivedProposal => "received_proposal_part", + Self::StartedRound => "started_round", + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct SkewNilVoteSourceLabel { + path: &'static str, +} + +impl SkewNilVoteSourceLabel { + fn new(source: SkewNilVoteSource) -> Self { + Self { + path: source.as_str(), + } + } +} + +/// Path that found a payload not bound to its place in the chain and stopped the +/// node, used to label the counter. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum BindingHaltSite { + /// A decided payload, found before it could be finalized. + Decided, + /// A block this node built for an earlier attempt at the same height. + ReusedBlock, + /// The node's own previous block, which is not the predecessor of the height + /// it is about to propose at. + PreviousBlock, + /// A stored payload the handshake replays to bring the execution client + /// forward. This one stops the node at startup, and the process aborts within + /// milliseconds. A scrape usually misses it, so an absent `replay` count is no + /// evidence either way: go to the startup logs. + Replay, +} + +impl BindingHaltSite { + fn as_str(&self) -> &'static str { + match self { + Self::Decided => "decided", + Self::ReusedBlock => "reused_block", + Self::PreviousBlock => "previous_block", + Self::Replay => "replay", + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct BindingHaltLabel { + site: &'static str, +} + +impl BindingHaltLabel { + fn new(site: BindingHaltSite) -> Self { + Self { + site: site.as_str(), + } + } +} + +/// Handler path that produced a transient (no-verdict) validation error, used +/// to label the counter. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum TransientValidationSource { + /// Validating freshly assembled proposal parts when a round starts. + StartedRoundPending, + /// Re-validating already-stored undecided blocks when a round starts. + StartedRoundRevalidation, +} + +impl TransientValidationSource { + #[cfg(test)] + pub(super) const ALL: [TransientValidationSource; 2] = + [Self::StartedRoundPending, Self::StartedRoundRevalidation]; + + fn as_str(&self) -> &'static str { + match self { + Self::StartedRoundPending => "started_round_pending", + Self::StartedRoundRevalidation => "started_round_revalidation", + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct TransientValidationSourceLabel { + source: &'static str, +} + +impl TransientValidationSourceLabel { + fn new(source: TransientValidationSource) -> Self { + Self { + source: source.as_str(), + } + } +} + #[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] pub struct VersionInfoLabel { pub version: &'static str, @@ -641,4 +1003,44 @@ mod tests { "Metrics should not contain 0x prefix: {buf}" ); } + + #[test] + fn test_consensus_round_missed_counter() { + let metrics = AppMetrics::new(); + let proposer = Address::new([0xAA; 20]); + + metrics.inc_consensus_round_missed(proposer); + metrics.inc_consensus_round_missed(proposer); + metrics.inc_consensus_round_missed(proposer); + + assert_eq!(metrics.get_consensus_round_missed_count(proposer), 3); + } + + #[test] + fn transient_validation_errors_count_registers_and_increments_per_source() { + let registry = SharedRegistry::global().with_moniker("transient_validation_errors_test"); + let metrics = AppMetrics::register(®istry); + + metrics + .inc_transient_validation_errors_count(TransientValidationSource::StartedRoundPending); + metrics + .inc_transient_validation_errors_count(TransientValidationSource::StartedRoundPending); + metrics.inc_transient_validation_errors_count( + TransientValidationSource::StartedRoundRevalidation, + ); + + assert_eq!( + metrics.get_transient_validation_errors_count_by_source( + TransientValidationSource::StartedRoundPending + ), + 2, + ); + assert_eq!( + metrics.get_transient_validation_errors_count_by_source( + TransientValidationSource::StartedRoundRevalidation + ), + 1, + ); + assert_eq!(metrics.get_transient_validation_errors_count(), 3); + } } diff --git a/crates/malachite-app/src/metrics/mod.rs b/crates/malachite-app/src/metrics/mod.rs index 90628bd1..258d1c14 100644 --- a/crates/malachite-app/src/metrics/mod.rs +++ b/crates/malachite-app/src/metrics/mod.rs @@ -19,7 +19,9 @@ pub mod db; pub mod process; pub mod validator_set; -pub use app::{AppMetrics, InvalidPayloadSource}; +pub use app::{ + AppMetrics, BindingHaltSite, InvalidPayloadSource, SkewNilVoteSource, TransientValidationSource, +}; pub use db::DbMetrics; pub use process::ProcessMetrics; pub use validator_set::ValidatorSetMetrics; diff --git a/crates/malachite-app/src/node.rs b/crates/malachite-app/src/node.rs index ae51a753..36988532 100644 --- a/crates/malachite-app/src/node.rs +++ b/crates/malachite-app/src/node.rs @@ -77,6 +77,23 @@ const APP_REQUEST_CHANNEL_SIZE: usize = 64; /// Note that ConsensusRequest::dump_state blocks until the response is sent back const CONSENSUS_REQUEST_CHANNEL_SIZE: usize = 5; +/// Overall budget for connecting to the execution engine and resolving chain +/// identity at startup. Exceeding it means the EL is unreachable. +const STARTUP_ENGINE_RESOLVE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Delay between chain-identity resolution attempts. Each attempt rebuilds the +/// engine connection. +const STARTUP_ENGINE_RECONNECT_BACKOFF: Duration = Duration::from_secs(1); + +/// Marker attached to a startup failure when the execution engine stays unreachable +/// past the resolution deadline. It distinguishes a transient, restart-recoverable +/// condition from an unrecoverable one (corrupt store, bad genesis/keys): the +/// former exits non-zero so the orchestrator restarts the node, the latter parks +/// for manual intervention. +#[derive(Debug, thiserror::Error)] +#[error("execution engine unreachable during startup")] +struct ExecutionEngineUnreachable; + /// Main application struct implementing the consensus node functionality pub struct App { /// The configuration for the node @@ -100,6 +117,9 @@ pub struct Handle { pub store_monitor: JoinHandle<()>, pub tx_event: TxEvent, pub cancel_token: CancellationToken, + /// Set before the Node actor is stopped so the application loop treats the resulting + /// consensus-channel close as a clean shutdown rather than an error. + graceful_shutdown: CancellationToken, /// Fires when the EL IPC watchdog triggered shutdown (as opposed to SIGTERM or normal halt). el_watchdog_triggered: oneshot::Receiver<()>, /// Kept alive to prevent the app request channel from closing when RPC is disabled. @@ -179,6 +199,22 @@ impl NodeIdentity { } } +/// Validate that `parallel_requests * batch_size` does not overflow `usize`. +/// +/// This product bounds the consensus input buffer (`max_pending_proposals`), +/// which computes it with `checked_mul().expect(..)` and would panic on +/// overflow. Env overrides can push these counts arbitrarily high, so validate +/// the effective product here and fail startup with a clear, actionable error. +fn check_pending_proposals_bound(parallel_requests: usize, batch_size: usize) -> eyre::Result<()> { + parallel_requests.checked_mul(batch_size).ok_or_else(|| { + eyre::eyre!( + "value_sync parallel_requests ({parallel_requests}) * batch_size ({batch_size}) \ + overflows usize; reduce ARC_SYNC_PARALLEL_REQUESTS and/or ARC_SYNC_BATCH_SIZE" + ) + })?; + Ok(()) +} + impl App { pub fn new( mut config: Config, @@ -438,10 +474,50 @@ impl App { Ok((store, store_monitor)) } - fn override_config_from_env(&mut self, env_config: &EnvConfig) { - if let Some(interval) = env_config.status_update_interval { - self.config.value_sync.status_update_interval = interval; + fn override_config_from_env(&mut self, env_config: &EnvConfig) -> eyre::Result<()> { + // Record every override that actually takes effect, keyed by the env var + // an operator sets, so a stale or forgotten variable is visible at startup. + let mut applied: Vec = Vec::new(); + + // Apply an optional override to a config field and record it under its env + // var name. `$fmt` is the value formatter: `{:?}` for durations, `{}` for + // byte sizes and counts. + macro_rules! apply { + ($src:expr => $dst:expr, $var:literal, $fmt:literal) => { + if let Some(v) = $src { + $dst = v; + applied.push(format!(concat!($var, "=", $fmt), v)); + } + }; + } + + let value_sync = &mut self.config.value_sync; + apply!(env_config.status_update_interval => value_sync.status_update_interval, "ARC_SYNC_STATUS_UPDATE_INTERVAL", "{:?}"); + apply!(env_config.value_sync_request_timeout => value_sync.request_timeout, "ARC_SYNC_REQUEST_TIMEOUT", "{:?}"); + apply!(env_config.value_sync_max_request_size => value_sync.max_request_size, "ARC_SYNC_MAX_REQUEST_SIZE", "{}"); + apply!(env_config.value_sync_max_response_size => value_sync.max_response_size, "ARC_SYNC_MAX_RESPONSE_SIZE", "{}"); + apply!(env_config.value_sync_parallel_requests => value_sync.parallel_requests, "ARC_SYNC_PARALLEL_REQUESTS", "{}"); + apply!(env_config.value_sync_inactive_threshold => value_sync.inactive_threshold, "ARC_SYNC_INACTIVE_THRESHOLD", "{:?}"); + apply!(env_config.value_sync_batch_size => value_sync.batch_size, "ARC_SYNC_BATCH_SIZE", "{}"); + + check_pending_proposals_bound(value_sync.parallel_requests, value_sync.batch_size)?; + + let consensus = &mut self.config.consensus; + apply!(env_config.wal_replay_delay => consensus.wal_replay_delay, "ARC_CONSENSUS_WAL_REPLAY_DELAY", "{:?}"); + apply!(env_config.queue_capacity => consensus.queue_capacity, "ARC_CONSENSUS_QUEUE_CAPACITY", "{}"); + apply!(env_config.queue_per_height_capacity => consensus.queue_per_height_capacity, "ARC_CONSENSUS_QUEUE_PER_HEIGHT_CAPACITY", "{}"); + apply!(env_config.ephemeral_connection_timeout => consensus.p2p.discovery.ephemeral_connection_timeout, "ARC_DISCOVERY_EPHEMERAL_CONNECTION_TIMEOUT", "{:?}"); + + // Only applies under remote signing; inside the branch `apply!` handles it. + if let SigningConfig::Remote(ref mut cfg) = self.config.signing { + apply!(env_config.remote_signing_timeout => cfg.timeout, "ARC_REMOTE_SIGNING_TIMEOUT", "{:?}"); + } + + if !applied.is_empty() { + info!(overrides = ?applied, "Applied environment variable overrides to consensus config"); } + + Ok(()) } /// Must run after `resolve_chain_identity` and before the engine is started, @@ -483,6 +559,13 @@ impl App { NetworkIdentity::new(identity.moniker.clone(), identity.p2p.keypair.clone(), None) }; + #[cfg(feature = "byzantine")] + if let Some(byz_cfg) = self.config.byzantine.clone().filter(|c| c.is_active()) { + return self + .start_byzantine_engine(ctx, identity, network_identity, wal_path, byz_cfg) + .await; + } + let (channels, engine_handle) = malachitebft_app_channel::start_engine( ctx, self.config.clone(), @@ -499,6 +582,80 @@ impl App { } } + /// Start the consensus engine with Byzantine behavior enabled. + /// + /// Uses the `with_byzantine_network` builder method on `app-channel` to + /// spawn the real network actor and wrap it with a `ByzantineNetworkProxy` + /// that intercepts votes and proposals per [`ByzantineConfig`]. State- + /// machine-layer attacks (`ignore_locks`, `force_precommit_nil`) are + /// handled inside `ArcContext::new_prevote` / `new_precommit` when + /// `ArcContext.byzantine` is set. + #[cfg(feature = "byzantine")] + async fn start_byzantine_engine( + &self, + ctx: ArcContext, + identity: NodeIdentity, + network_identity: NetworkIdentity, + wal_path: std::path::PathBuf, + byz_cfg: arc_consensus_types::config::ByzantineConfig, + ) -> eyre::Result<(Channels, EngineHandle)> { + use arc_consensus_types::{Value, ValueId}; + use malachitebft_app_channel::{ByzantineContext, EngineBuilder}; + + warn!( + ?byz_cfg, + "BYZANTINE: Starting node with Byzantine behavior enabled" + ); + + let address = identity.consensus.address(); + let signing_provider = identity.consensus.signing_provider().clone(); + + // Conflicting value factory: flip the last byte of the block hash. + let conflicting_value_fn: malachitebft_engine_byzantine::ConflictingValueFn = + Box::new(|original: &Value| { + let mut hash = original.id().block_hash(); + let bytes = hash.as_mut_slice(); + bytes[31] ^= 0xFF; + Value::new(hash) + }); + + // Conflicting vote value factory: flip the last byte of the id bytes; + // nil votes are replaced with a zero id that still differs after flip. + let conflicting_vote_value_fn: malachitebft_engine_byzantine::ConflictingVoteValueFn< + ArcContext, + > = Box::new(|original: Option<&ValueId>| match original { + Some(id) => { + let mut hash = id.block_hash(); + let bytes = hash.as_mut_slice(); + bytes[31] ^= 0xFF; + ValueId::new(hash) + } + None => ValueId::new(Default::default()), + }); + + let (channels, engine_handle) = EngineBuilder::new(ctx, self.config.clone()) + .with_default_wal(WalContext::new(wal_path, WalCodec)) + .with_byzantine_network(ByzantineContext { + identity: network_identity, + codec: NetCodec, + config: byz_cfg, + signer: Box::new(signing_provider), + address, + conflicting_value_fn: Some(conflicting_value_fn), + conflicting_vote_value_fn: Some(conflicting_vote_value_fn), + }) + .await + .wrap_err("Failed to install Byzantine network proxy")? + .with_default_consensus(ConsensusContext::from(identity.consensus)) + .with_default_sync(SyncContext::new(NetCodec)) + .with_default_request(RequestContext::new(CONSENSUS_REQUEST_CHANNEL_SIZE)) + .build() + .await + .wrap_err("Failed to start Byzantine consensus engine")?; + + Ok((channels, engine_handle)) + } + /// Create a NetworkIdentity with a signed validator proof. /// /// The validator proof binds the consensus public key to the libp2p peer ID, @@ -566,6 +723,7 @@ impl App { fn start_rpc_server( &self, channels: &Channels, + metrics: AppMetrics, ) -> ( mpsc::Sender, mpsc::Receiver, @@ -576,13 +734,17 @@ impl App { let rpc_handle = if self.config.rpc.enabled { let join_handle = tokio::spawn({ let listen_addr = self.config.rpc.listen_addr; + let admin_enabled = self.config.rpc.admin; let request_handle = channels.requests.clone(); let net_request_handle = channels.net_requests.clone(); - crate::rpc::serve( + let metrics = metrics.clone(); + crate::rpc::serve_with_metrics( listen_addr, request_handle, tx_rpc_req.clone(), net_request_handle, + admin_enabled, + metrics, ) }); Some(join_handle) @@ -605,42 +767,13 @@ impl App { } } - /// Query the execution engine to retrieve the chain ID and genesis block. - /// - /// These are used during node startup to compute the initial network ID - /// and to configure the consensus state with the genesis block's hash and timestamp. - async fn resolve_chain_identity( - &self, - engine: &Engine, - ) -> eyre::Result<(ChainId, ExecutionBlock)> { - let eth_chain_id = engine - .eth - .get_chain_id() - .await - .wrap_err("Failed to get chain ID from execution engine")?; - - let chain_id: ChainId = eth_chain_id - .parse() - .wrap_err("Invalid chain ID from execution engine")?; - - let genesis_block = engine - .eth - .get_genesis_block() - .await - .wrap_err("Failed to get genesis block from execution engine")?; - - Ok((chain_id, genesis_block)) - } - #[tracing::instrument(name = "node", skip_all, fields(moniker = %self.config.moniker))] pub async fn start(&mut self) -> eyre::Result { - let ctx = ArcContext::new(); - // Read environment-based configuration once at startup - let env_config = EnvConfig::from_env(); + let env_config = EnvConfig::from_env()?; // Apply config overrides from environment variables - self.override_config_from_env(&env_config); + self.override_config_from_env(&env_config)?; // Setup node identity, uses ephemeral keys in follow mode (RPC sync mode)) let identity = self.setup_node_identity().await?; @@ -653,13 +786,16 @@ impl App { .open_store(db_metrics, env_config.db_cache_size) .await?; - // Connect to the execution engine early so we can resolve consensus spec and genesis hash - let engine = self.connect_to_execution_engine().await?; - - let (chain_id, genesis_block) = self - .resolve_chain_identity(&engine) - .await - .wrap_err("Failed to resolve chain identity from execution engine")?; + // Connect to the execution engine and resolve consensus spec and genesis + // hash. The EL may be restarting underneath us, so retry with a fresh + // connection until it serves or the startup deadline elapses. + let (engine, chain_id, genesis_block) = connect_and_resolve_chain_identity( + || self.connect_to_execution_engine(), + STARTUP_ENGINE_RESOLVE_TIMEOUT, + STARTUP_ENGINE_RECONNECT_BACKOFF, + ) + .await + .wrap_err("Failed to resolve chain identity from execution engine")?; self.apply_chain_specific_config(chain_id); @@ -689,8 +825,35 @@ impl App { "Resolved chain identity from execution engine" ); + // Build the consensus context. When the byzantine feature is enabled + // and the config opts in, attach a `ByzantineState` bundle so + // `ArcContext::new_prevote` / `new_precommit` can intercept votes. + #[cfg(feature = "byzantine")] + let ctx = { + let ctx = ArcContext::new(); + match self.config.byzantine.as_ref() { + Some(byz) => { + use arc_consensus_types::context::ByzantineState; + use std::sync::Arc; + let state = Arc::new(ByzantineState::new( + byz.ignore_locks.clone(), + byz.force_precommit_nil.clone(), + identity.consensus.address(), + byz.seed, + )); + ctx.with_byzantine(state) + } + None => ctx, + } + }; + #[cfg(not(feature = "byzantine"))] + let ctx = ArcContext::new(); + // Initialize the application state with the resolved spec and genesis block - let mut state = State::builder(ctx) + // NOTE: ArcContext is not always Copy, depending on whether + // `byzantine` feature is enabled, so we clone it here to avoid a conditional compilation warning. + #[allow(clippy::redundant_clone)] + let mut state = State::builder(ctx.clone()) .identity(identity.consensus.clone()) .store(store.clone()) .config(self.config.clone()) @@ -710,14 +873,15 @@ impl App { let (channels, engine_handle) = self.start_consensus_engine(ctx, identity).await?; // Start the application RPC server - let (tx_app_req, rx_app_req, rpc_handle) = self.start_rpc_server(&channels); + let (tx_app_req, rx_app_req, rpc_handle) = + self.start_rpc_server(&channels, state.metrics().clone()); let tx_event = channels.events.clone(); let cancel_token = CancellationToken::new(); + let graceful_shutdown = CancellationToken::new(); - // Watchdog: cancel the app task if the EL IPC connection closes unexpectedly. - // run() will detect the signal and return an error, letting the tokio runtime - // unwind naturally (running all Drop implementations) instead of process::exit. + // Watchdog: on unexpected EL IPC close, signal the run loop and cancel the app task; + // the run loop performs the bounded Node stop and the process exit. let engine_for_watchdog = engine.clone(); let (el_watchdog_tx, el_watchdog_rx) = oneshot::channel::<()>(); tokio::spawn({ @@ -725,9 +889,9 @@ impl App { async move { tokio::select! { _ = engine_for_watchdog.wait_for_disconnect() => { - tracing::error!("EL IPC connection closed; shutting down"); - // Send before cancel so the oneshot is filled before the app task - // can observe cancellation and exit, eliminating a try_recv race. + error!("EL IPC connection closed; shutting down"); + // Signal before cancelling so the run loop observes the trigger before + // the app task exits, eliminating a try_recv race. el_watchdog_tx.send(()).ok(); cancel_token.cancel(); } @@ -739,7 +903,15 @@ impl App { // Start the application task let app_handle = tokio::spawn({ let cancel_token = cancel_token.clone(); - crate::app::run(state, channels, engine, rx_app_req, cancel_token) + let graceful_shutdown = graceful_shutdown.clone(); + crate::app::run( + state, + channels, + engine, + rx_app_req, + cancel_token, + graceful_shutdown, + ) }); // Start the pprof server if enabled @@ -755,6 +927,7 @@ impl App { tx_event, store, cancel_token, + graceful_shutdown, el_watchdog_triggered: el_watchdog_rx, _tx_app_req: tx_app_req, }) @@ -771,11 +944,14 @@ impl App { Err(e) => { let startup_error = e.wrap_err("Node failed to start"); error!("{startup_error:?}"); - error!("Manual intervention required! Waiting for termination signal (SIGTERM)..."); - // Wait for SIGTERM to allow graceful shutdown - wait_for_termination().await; + if is_execution_engine_unreachable(&startup_error) { + error!("Execution engine unreachable during startup. Exiting..."); + return Err(startup_error); + } + error!("Manual intervention required! Waiting for termination signal (SIGTERM)..."); + wait_for_termination().await; return Err(startup_error); } }; @@ -786,11 +962,20 @@ impl App { // Wait for the application to finish let result = handles.app.await?; - // If the EL IPC watchdog triggered the shutdown, propagate an error so the - // caller (main) exits with a non-zero code. The tokio runtime unwinds naturally - // after run() returns, running all Drop implementations — no process::exit needed. + // EL IPC closed: stop the Node actor with a bounded timeout, then exit non-zero so + // the orchestrator restarts the container. if handles.el_watchdog_triggered.try_recv().is_ok() { - return Err(eyre::eyre!("EL IPC connection closed unexpectedly")); + stop_node_and_teardown( + handles.engine.actor.stop_and_wait( + Some("EL IPC connection closed".to_string()), + Some(Duration::from_secs(10)), + ), + &handles.cancel_token, + &handles.graceful_shutdown, + ) + .await; + drain_before_exit(|| handles.store.savepoint()).await; + std::process::exit(EL_IPC_SHUTDOWN_EXIT_CODE); } if let Err(e) = &result { @@ -818,6 +1003,122 @@ impl App { } } +/// Exit code for an EL IPC-triggered shutdown. Non-zero so the orchestrator restarts the +/// container. +const EL_IPC_SHUTDOWN_EXIT_CODE: i32 = 1; + +/// Exit code for a SIGTERM-triggered shutdown: 143 = 128 + SIGTERM (15), the conventional +/// exit status for a process terminated by SIGTERM. +const SIGTERM_EXIT_CODE: i32 = 143; + +/// Grace period for in-flight tasks to finish after teardown, before the process exits. +const SHUTDOWN_DRAIN_DELAY: Duration = Duration::from_millis(500); + +/// Stops the consensus Node actor, then tears down the application loop. +/// +/// Marks the shutdown as graceful before stopping the Node so the application treats the +/// consensus channel closing as a clean exit. Awaiting the Node stop first lets ractor +/// hard-kill the children (consensus, host, network, wal, sync) in order, so in-flight +/// effects never fail on a torn-down sibling. Teardown proceeds even when the Node stop +/// fails, so a stalled child cannot block shutdown. +async fn stop_node_and_teardown( + stop_node: impl std::future::Future>, + cancel_token: &CancellationToken, + graceful_shutdown: &CancellationToken, +) { + graceful_shutdown.cancel(); + + if let Err(e) = stop_node.await { + warn!(%e, "Failed to stop the node gracefully"); + } + + cancel_token.cancel(); +} + +/// Persists a database savepoint, then gives in-flight tasks a brief moment to finish +/// before the caller exits the process. +/// +/// The savepoint runs through a callback to keep this decoupled from the store. Callers +/// invoke [`std::process::exit`] afterwards; the explicit exit bounds shutdown time even +/// if a `Drop` implementation stalls during teardown. +async fn drain_before_exit(savepoint: impl FnOnce()) { + savepoint(); + + info!("Waiting for all tasks to finish..."); + tokio::time::sleep(SHUTDOWN_DRAIN_DELAY).await; + info!("Shutdown complete, exiting"); +} + +/// Query the execution engine for the chain ID and genesis block. +/// +/// Used during node startup to compute the initial network ID and to configure the +/// consensus state with the genesis block's hash and timestamp. +async fn resolve_chain_identity(engine: &Engine) -> eyre::Result<(ChainId, ExecutionBlock)> { + let eth_chain_id = engine + .eth + .get_chain_id() + .await + .wrap_err("Failed to get chain ID from execution engine")?; + + let chain_id: ChainId = eth_chain_id + .parse() + .wrap_err("Invalid chain ID from execution engine")?; + + let genesis_block = engine + .eth + .get_genesis_block() + .await + .wrap_err("Failed to get genesis block from execution engine")?; + + Ok((chain_id, genesis_block)) +} + +/// Connect to the execution engine and resolve chain identity, retrying with a fresh +/// connection until the engine serves or `deadline` elapses. +/// +/// Each attempt rebuilds the connection via `connect` to cover the case where +/// the EL is restarting and the IPC socket is not yet available. +async fn connect_and_resolve_chain_identity( + connect: C, + deadline: Duration, + backoff: Duration, +) -> eyre::Result<(Engine, ChainId, ExecutionBlock)> +where + C: Fn() -> F, + F: std::future::Future>, +{ + let resolved = tokio::time::timeout(deadline, async { + loop { + let engine = connect().await?; + match resolve_chain_identity(&engine).await { + Ok((chain_id, genesis_block)) => { + return Ok::<_, eyre::Report>((engine, chain_id, genesis_block)); + } + Err(e) => { + warn!("Failed to resolve chain identity, retrying: {e:?}"); + tokio::time::sleep(backoff).await; + } + } + } + }) + .await; + + match resolved { + Ok(result) => result, + Err(_) => Err(eyre::Report::new(ExecutionEngineUnreachable) + .wrap_err(format!("execution engine unreachable after {deadline:?}"))), + } +} + +/// Whether a startup error was caused by the execution engine being unreachable +/// past the resolution deadline (a transient, restart-recoverable condition). Walks +/// the error chain so the marker is found beneath the context layers added on the +/// way up through `start`/`run`. +fn is_execution_engine_unreachable(err: &eyre::Report) -> bool { + err.chain() + .any(|cause| cause.is::()) +} + /// Install a SIGTERM handler to gracefully shutdown the node /// /// ## Note @@ -825,44 +1126,31 @@ impl App { #[cfg(unix)] fn install_sigterm_handler(handle: &Handle) { use tokio::signal::unix::signal; - use tokio::time::sleep; let node = handle.engine.actor.clone(); let store = handle.store.clone(); let cancel_token = handle.cancel_token.clone(); + let graceful_shutdown = handle.graceful_shutdown.clone(); let mut sigterm = signal(SignalKind::terminate()).expect("inside Tokio runtime"); tokio::spawn(async move { - // Wait for the SIGTERM signal sigterm.recv().await; warn!("Received SIGTERM, shutting down..."); - // Trigger cancellation of the application - cancel_token.cancel(); - - // Give some time to the application to process the cancellation - sleep(Duration::from_millis(500)).await; - - // Stop the consensus engine - if let Err(e) = node - .stop_and_wait(Some("Received SIGTERM signal".to_string()), None) - .await - { - warn!(%e, "Failed to stop the node gracefully"); - } - - // Create a database savepoint ensuring no repair is needed on restart - store.savepoint(); - - info!("Waiting for all tasks to finish..."); - sleep(Duration::from_millis(500)).await; - info!("Shutdown complete, exiting"); + stop_node_and_teardown( + node.stop_and_wait( + Some("Received SIGTERM signal".to_string()), + Some(Duration::from_secs(10)), + ), + &cancel_token, + &graceful_shutdown, + ) + .await; - // In Kubernetes signals, exit code 143 means that a container - // was terminated by receiving a SIGTERM signal - std::process::exit(143); + drain_before_exit(|| store.savepoint()).await; + std::process::exit(SIGTERM_EXIT_CODE); }); } @@ -911,8 +1199,79 @@ fn spawn_pprof_server(_bind_address: std::net::SocketAddr, _heap_prof: bool) {} #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use arc_eth_engine::mocks::{MockEngineAPI, MockEthereumAPI}; use tempfile::tempdir; + /// Minimal genesis block for mock execution engines. + fn mock_genesis_block() -> ExecutionBlock { + ExecutionBlock { + block_hash: Default::default(), + block_number: 0, + parent_hash: Default::default(), + timestamp: 0, + } + } + + #[test] + fn check_pending_proposals_bound_accepts_normal_values() { + assert!(check_pending_proposals_bound(5, 10).is_ok()); + assert!(check_pending_proposals_bound(1, 1).is_ok()); + } + + #[tokio::test] + async fn stop_node_and_teardown_stops_node_before_tearing_down_app() { + let cancel_token = CancellationToken::new(); + let graceful_shutdown = CancellationToken::new(); + + let stop_node = { + let cancel_token = cancel_token.clone(); + let graceful_shutdown = graceful_shutdown.clone(); + async move { + // The shutdown is marked graceful and the application loop is still live + // while the Node is being stopped. + assert!(graceful_shutdown.is_cancelled()); + assert!(!cancel_token.is_cancelled()); + Ok::<(), std::convert::Infallible>(()) + } + }; + + stop_node_and_teardown(stop_node, &cancel_token, &graceful_shutdown).await; + + assert!(graceful_shutdown.is_cancelled()); + assert!(cancel_token.is_cancelled()); + } + + #[tokio::test(start_paused = true)] + async fn drain_before_exit_persists_savepoint_then_drains() { + let saved = std::cell::Cell::new(false); + + drain_before_exit(|| saved.set(true)).await; + + assert!(saved.get()); + } + + #[tokio::test] + async fn stop_node_and_teardown_tears_down_when_node_stop_fails() { + let cancel_token = CancellationToken::new(); + let graceful_shutdown = CancellationToken::new(); + + let stop_node = async { Err::<(), &str>("node stop timed out") }; + + stop_node_and_teardown(stop_node, &cancel_token, &graceful_shutdown).await; + + assert!(graceful_shutdown.is_cancelled()); + assert!(cancel_token.is_cancelled()); + } + + #[test] + fn check_pending_proposals_bound_rejects_overflow() { + let err = check_pending_proposals_bound(usize::MAX, 2).unwrap_err(); + assert!(err.to_string().contains("overflows usize")); + } + #[tokio::test] async fn start_requires_execution_engine() { let tmp = tempfile::tempdir().unwrap(); @@ -1008,4 +1367,105 @@ mod tests { // Each call produces a different address assert_ne!(id1.address(), id2.address()); } + + #[tokio::test] + async fn resolve_chain_identity_returns_chain_and_genesis() { + let mut eth = MockEthereumAPI::new(); + eth.expect_get_chain_id() + .returning(|| Ok("0x539".to_string())); + eth.expect_get_genesis_block() + .returning(|| Ok(mock_genesis_block())); + let engine = Engine::new(Box::new(MockEngineAPI::new()), Box::new(eth)); + + let (chain_id, genesis) = resolve_chain_identity(&engine).await.unwrap(); + + assert_eq!(chain_id, ChainId::Localdev); // 0x539 == 1337 + assert_eq!(genesis.block_number, 0); + } + + #[tokio::test(start_paused = true)] + async fn resolve_with_reconnect_recovers_after_transient_el_failures() { + let attempts = Arc::new(AtomicUsize::new(0)); + let connect_calls = Arc::new(AtomicUsize::new(0)); + + let connect = { + let attempts = attempts.clone(); + let connect_calls = connect_calls.clone(); + move || { + connect_calls.fetch_add(1, Ordering::SeqCst); + let attempts = attempts.clone(); + async move { + let n = attempts.fetch_add(1, Ordering::SeqCst); + let mut eth = MockEthereumAPI::new(); + if n < 2 { + // Held connection is to a terminating reth: reset before it + // answers. + eth.expect_get_chain_id().returning(|| { + Err(eyre::eyre!("Connection reset by peer (os error 104)")) + }); + } else { + eth.expect_get_chain_id() + .returning(|| Ok("0x539".to_string())); + eth.expect_get_genesis_block() + .returning(|| Ok(mock_genesis_block())); + } + Ok(Engine::new(Box::new(MockEngineAPI::new()), Box::new(eth))) + } + } + }; + + let (_engine, chain_id, genesis) = connect_and_resolve_chain_identity( + connect, + Duration::from_secs(30), + Duration::from_millis(10), + ) + .await + .expect("should resolve once the replacement EL serves"); + + assert_eq!(chain_id, ChainId::Localdev); + assert_eq!(genesis.block_number, 0); + assert!( + connect_calls.load(Ordering::SeqCst) >= 3, + "each retry must rebuild the connection (reconnect), got {}", + connect_calls.load(Ordering::SeqCst) + ); + } + + #[tokio::test(start_paused = true)] + async fn resolve_with_reconnect_errors_when_el_unreachable_past_deadline() { + let connect = || async { + let mut eth = MockEthereumAPI::new(); + eth.expect_get_chain_id() + .returning(|| Err(eyre::eyre!("Connection reset by peer (os error 104)"))); + Ok(Engine::new(Box::new(MockEngineAPI::new()), Box::new(eth))) + }; + + let err = connect_and_resolve_chain_identity( + connect, + Duration::from_secs(30), + Duration::from_secs(1), + ) + .await + .err() + .expect("should error when the EL never serves"); + + assert!( + is_execution_engine_unreachable(&err), + "deadline error must carry the ExecutionEngineUnreachable marker: {err:?}" + ); + } + + #[test] + fn is_execution_engine_unreachable_classifies_marker() { + // Marker wrapped in the same context layers `run()` observes (resolve, then + // start). + let tagged = eyre::Report::new(ExecutionEngineUnreachable) + .wrap_err("Failed to resolve chain identity from execution engine") + .wrap_err("Node failed to start"); + assert!(is_execution_engine_unreachable(&tagged)); + + // An unrecoverable startup error (e.g. corrupt store) carries no marker. + let other = eyre::eyre!("corrupt store").wrap_err("Node failed to start"); + assert!(!is_execution_engine_unreachable(&other)); + } } diff --git a/crates/malachite-app/src/payload.rs b/crates/malachite-app/src/payload.rs index 2a4df9a2..e4c22aed 100644 --- a/crates/malachite-app/src/payload.rs +++ b/crates/malachite-app/src/payload.rs @@ -23,7 +23,8 @@ use malachitebft_app_channel::app::types::core::Validity; use alloy_rpc_types_engine::{ExecutionPayloadV3, PayloadStatusEnum}; -use arc_consensus_types::Address; +use arc_consensus_types::{Address, BlockHash, Height, Round}; +use arc_eth_engine::deadline::EngineDeadline; use arc_eth_engine::engine::Engine; use arc_eth_engine::json_structures::ExecutionBlock; use arc_eth_engine::rpc::EngineApiRpcError; @@ -109,6 +110,9 @@ pub trait PayloadGenerator: Send + Sync { pub struct EnginePayloadGenerator<'a> { pub engine: &'a Engine, + /// Consensus budget for the proposer's build sequence; extends the + /// engine's per-call timeout floors when set. + pub deadline: Option, } impl<'a> PayloadGenerator for EnginePayloadGenerator<'a> { @@ -119,7 +123,7 @@ impl<'a> PayloadGenerator for EnginePayloadGenerator<'a> { fee_recipient: &Address, ) -> eyre::Result { self.engine - .generate_block(parent, timestamp, fee_recipient) + .generate_block(parent, timestamp, fee_recipient, self.deadline) .await } } @@ -158,11 +162,30 @@ where pub struct EnginePayloadValidator<'a> { engine: &'a Engine, metrics: &'a AppMetrics, + deadline: Option, } impl<'a> EnginePayloadValidator<'a> { pub fn new(engine: &'a Engine, metrics: &'a AppMetrics) -> Self { - Self { engine, metrics } + Self { + engine, + metrics, + deadline: None, + } + } + + /// Validator for the proposer's self-validation path: the consensus + /// budget extends the engine's per-call timeout floor. + pub fn new_with_deadline( + engine: &'a Engine, + metrics: &'a AppMetrics, + deadline: EngineDeadline, + ) -> Self { + Self { + engine, + metrics, + deadline: Some(deadline), + } } } @@ -171,7 +194,7 @@ impl PayloadValidator for EnginePayloadValidator<'_> { &self, payload: &ExecutionPayloadV3, ) -> eyre::Result { - validate_payload(self.engine, payload, self.metrics).await + validate_payload(self.engine, payload, self.metrics, self.deadline).await } } @@ -202,17 +225,17 @@ impl std::fmt::Display for PayloadValidationResult { /// /// # Return values /// -/// - `Ok(Valid)`: the engine accepted the payload, or returned an unexpected status -/// such as `SYNCING` or `ACCEPTED` (logged as a warning). -/// - `Ok(Invalid { reason })`: the engine explicitly rejected the payload, either -/// via its status response (`INVALID`) or via a JSON-RPC error -/// (`EngineApiRpcError`). -/// - `Err(..)`: the engine replied with status `SYNCING` or `ACCEPTED`, or an -/// unrelated internal error occurred in the call stack. +/// - `Ok(Valid)`: the engine accepted the payload. +/// - `Ok(Invalid { reason })`: the engine rejected the payload, either via its +/// status response (`INVALID`) or via a non-internal JSON-RPC error. +/// - `Err(..)`: no verdict was obtained — the engine replied with an unexpected +/// status (`SYNCING` or `ACCEPTED`, logged as a warning), returned a JSON-RPC +/// internal error, or an unrelated internal error occurred in the call stack. async fn validate_payload( engine: &Engine, execution_payload: &ExecutionPayloadV3, metrics: &AppMetrics, + deadline: Option, ) -> eyre::Result { let block_hash = execution_payload.payload_inner.payload_inner.block_hash; @@ -232,7 +255,7 @@ async fn validate_payload( let _guard = metrics.start_engine_api_timer("notify_new_block"); match engine - .notify_new_block(execution_payload, versioned_hashes) + .notify_new_block(execution_payload, versioned_hashes, deadline) .await { Ok(status) => match status.status { @@ -262,21 +285,18 @@ async fn validate_payload( }, Err(e) => { if let Ok(engine_api_error) = EngineApiRpcError::try_from(&e) { - // JSON-RPC error here means that the call to - // `engine.newPayload` failed the preliminary structural - // validation of the payload. - // Instead of returning an error and possibly crashing the app, - // we mark the payload as invalid. - error!( - %block_hash, - "Invalid payload: {engine_api_error}", - ); - return Ok(PayloadValidationResult::Invalid { - reason: engine_api_error.to_string(), - }); + if !engine_api_error.is_internal_error() { + error!( + %block_hash, + "Invalid payload: {engine_api_error}", + ); + return Ok(PayloadValidationResult::Invalid { + reason: engine_api_error.to_string(), + }); + } } - // Unrelated internal error in the call stack. + // Internal failures provide no deterministic payload verdict. let msg = format!( "call to EngineAPI::new_payload failed when validating block: {block_hash}", ); @@ -321,7 +341,7 @@ pub async fn validate_consensus_block( warn!( height = %block.height, round = %block.round, - block_hash = %block.block_hash(), + block_hash = %block.self_reported_block_hash(), proposer = %block.proposer, reason = %reason, "Engine rejected payload, storing for forensics", @@ -332,7 +352,7 @@ pub async fn validate_consensus_block( error!( height = %block.height, round = %block.round, - block_hash = %block.block_hash(), + block_hash = %block.self_reported_block_hash(), proposer = %block.proposer, "Failed to persist invalid-payload forensic record: {e}", ); @@ -342,6 +362,163 @@ pub async fn validate_consensus_block( } } +/// An execution payload that does not belong at its place in the chain. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PayloadBindingError { + #[error("payload block number {actual} does not match consensus height {expected}")] + HeightMismatch { expected: u64, actual: u64 }, + + #[error( + "payload parent hash {actual} is not the block finalized at the previous height ({expected})" + )] + ParentMismatch { + expected: BlockHash, + actual: BlockHash, + }, +} + +impl PayloadBindingError { + /// The counter label for this rule. A wrong block number is a property of the + /// payload alone. A wrong parent compares it against local state, so that label + /// also rises when this node holds the wrong view of the previous height. + pub fn invalid_payload_source(&self) -> InvalidPayloadSource { + match self { + Self::HeightMismatch { .. } => InvalidPayloadSource::PayloadHeight, + Self::ParentMismatch { .. } => InvalidPayloadSource::PayloadParent, + } + } +} + +/// Makes sure that an execution payload belongs at the given consensus height. +/// +/// Arc keeps one execution block per consensus height. A payload therefore +/// carries that height as its block number, and it extends the block that the +/// node finalized at the height before. +/// +/// The parent rule needs the immediate predecessor. During batch value sync, +/// `previous_block` can lag by several heights. This function therefore applies +/// the parent rule only when `previous_block` sits one height below `height`. +pub fn check_payload_binding( + payload: &ExecutionPayloadV3, + height: Height, + previous_block: Option<&ExecutionBlock>, +) -> Result<(), PayloadBindingError> { + let payload = &payload.payload_inner.payload_inner; + let height = height.as_u64(); + + if payload.block_number != height { + return Err(PayloadBindingError::HeightMismatch { + expected: height, + actual: payload.block_number, + }); + } + + let Some(previous) = previous_block else { + return Ok(()); + }; + + if previous.block_number.checked_add(1) != Some(height) { + return Ok(()); + } + + if payload.parent_hash != previous.block_hash { + return Err(PayloadBindingError::ParentMismatch { + expected: previous.block_hash, + actual: payload.parent_hash, + }); + } + + Ok(()) +} + +/// A validity verdict together with the rule that produced it. +/// +/// A caller that reconciles a fresh verdict with a stored one needs to know +/// which rule spoke. Only an engine verdict can differ between two runs against +/// the same parent state, so only an engine verdict describes replay divergence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BlockVerdict { + /// The binding rules rejected the payload, and this is the rule that did. The + /// engine was not asked. The rule reaches the caller because the two do not + /// read alike: one blames the payload, the other can blame this node. + Unbound(PayloadBindingError), + /// The engine returned this verdict. + Engine(Validity), +} + +impl BlockVerdict { + pub fn validity(&self) -> Validity { + match self { + Self::Unbound(_) => Validity::Invalid, + Self::Engine(validity) => *validity, + } + } +} + +/// Establishes the validity of a block that arrived from the network. +/// +/// The binding rules run first. The engine never sees a payload that breaks +/// them, so such a payload never enters the block tree of the execution client. +/// A payload that keeps the rules gets its verdict from [`validate_consensus_block`]. +pub async fn establish_block_validity( + payload_validator: &impl PayloadValidator, + block: &ConsensusBlock, + previous_block: Option<&ExecutionBlock>, + store: &impl InvalidPayloadsRepository, + metrics: &AppMetrics, +) -> eyre::Result { + if let Err(error) = + check_payload_binding(&block.execution_payload, block.height, previous_block) + { + warn!( + height = %block.height, + round = %block.round, + block_hash = %block.self_reported_block_hash(), + proposer = %block.proposer, + reason = %error, + "Payload is not bound to its place in the chain, storing for forensics", + ); + + metrics.inc_invalid_payloads_count(error.invalid_payload_source()); + + persist_invalid_payload_best_effort( + store, + InvalidPayload::new_from_block(block, &error.to_string()), + block.height, + block.round, + block.proposer, + ) + .await; + + return Ok(BlockVerdict::Unbound(error)); + } + + validate_consensus_block(payload_validator, block, store, metrics) + .await + .map(BlockVerdict::Engine) +} + +/// Persists a forensic [`InvalidPayload`] record on a best-effort basis. +/// +/// A persistence failure is logged at `error` and swallowed so it cannot abort the +/// caller's primary path. For call sites where the block was never assembled, so only +/// `height`, `round`, and `proposer` are available; [`validate_consensus_block`] logs +/// `block_hash` too because it has the assembled block. +pub(crate) async fn persist_invalid_payload_best_effort( + store: &impl InvalidPayloadsRepository, + invalid: InvalidPayload, + height: Height, + round: Round, + proposer: Address, +) { + if let Err(e) = store.append(invalid).await { + error!( + %height, %round, %proposer, + "Failed to persist invalid-payload forensic record: {e}", + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -394,7 +571,7 @@ mod tests { #[tokio::test] async fn validate_payload_returns_valid_on_ok_status() { let mut mock = MockEngineAPI::new(); - mock.expect_new_payload().returning(|_, _, _| { + mock.expect_new_payload().returning(|_, _, _, _| { Ok(PayloadStatus { status: PayloadStatusEnum::Valid, latest_valid_hash: None, @@ -405,7 +582,7 @@ mod tests { let payload = test_payload(0); let metrics = AppMetrics::default(); - let result = validate_payload(&engine, &payload, &metrics) + let result = validate_payload(&engine, &payload, &metrics, None) .await .expect("payload validation should succeed"); @@ -415,7 +592,7 @@ mod tests { #[tokio::test] async fn validate_payload_returns_invalid_on_invalid_status() { let mut mock = MockEngineAPI::new(); - mock.expect_new_payload().returning(|_, _, _| { + mock.expect_new_payload().returning(|_, _, _, _| { Ok(PayloadStatus { status: PayloadStatusEnum::Invalid { validation_error: "validation error".to_string(), @@ -428,7 +605,7 @@ mod tests { let payload = test_payload(0); let metrics = AppMetrics::default(); - let result = validate_payload(&engine, &payload, &metrics) + let result = validate_payload(&engine, &payload, &metrics, None) .await .expect("payload validation should succeed"); @@ -441,9 +618,9 @@ mod tests { } #[tokio::test] - async fn validate_payload_returns_invalid_on_rpc_error() { + async fn validate_payload_returns_invalid_on_non_internal_rpc_error() { let mut mock = MockEngineAPI::new(); - mock.expect_new_payload().returning(|_, _, _| { + mock.expect_new_payload().returning(|_, _, _, _| { let rpc_error = EngineApiRpcError::new(42, "engine API error", None); Err(eyre::Report::new(rpc_error)) }); @@ -452,7 +629,7 @@ mod tests { let payload = test_payload(0); let metrics = AppMetrics::default(); - let result = validate_payload(&engine, &payload, &metrics) + let result = validate_payload(&engine, &payload, &metrics, None) .await .expect("should succeed without error"); @@ -469,17 +646,43 @@ mod tests { } } + #[tokio::test] + async fn validate_payload_propagates_internal_rpc_error() { + let mut mock = MockEngineAPI::new(); + mock.expect_new_payload().returning(|_, _, _, _| { + let rpc_error = EngineApiRpcError::new(-32603, "Internal error", None); + Err(eyre::Report::new(rpc_error)) + }); + + let engine = Engine::new(Box::new(mock), Box::new(MockEthereumAPI::new())); + let payload = test_payload(0); + let metrics = AppMetrics::default(); + + let err = validate_payload(&engine, &payload, &metrics, None) + .await + .expect_err("internal RPC error should not produce a payload verdict"); + + let engine_api_error = EngineApiRpcError::try_from(&err) + .expect("error chain should preserve the Engine API error"); + assert!(engine_api_error.is_internal_error()); + assert!( + err.to_string() + .contains("call to EngineAPI::new_payload failed"), + "error message should describe the failure, got: {err}", + ); + } + #[tokio::test] async fn validate_payload_propagates_other_errors() { let mut mock = MockEngineAPI::new(); mock.expect_new_payload() - .returning(|_, _, _| Err(eyre::eyre!("some error"))); + .returning(|_, _, _, _| Err(eyre::eyre!("some error"))); let engine = Engine::new(Box::new(mock), Box::new(MockEthereumAPI::new())); let payload = test_payload(0); let metrics = AppMetrics::default(); - let err = validate_payload(&engine, &payload, &metrics) + let err = validate_payload(&engine, &payload, &metrics, None) .await .expect_err("payload validation should return an error"); @@ -497,7 +700,7 @@ mod tests { for status in test_cases { let mut mock = MockEngineAPI::new(); let status_for_mock = status.clone(); - mock.expect_new_payload().returning(move |_, _, _| { + mock.expect_new_payload().returning(move |_, _, _, _| { Ok(PayloadStatus { status: status_for_mock.clone(), latest_valid_hash: None, @@ -508,7 +711,7 @@ mod tests { let payload = test_payload(0); let metrics = AppMetrics::default(); - let result = validate_payload(&engine, &payload, &metrics) + let result = validate_payload(&engine, &payload, &metrics, None) .await .expect_err("payload validation should return an error"); @@ -539,6 +742,232 @@ mod tests { } } + /// Payload that carries `height` as its block number and `parent_hash` as its parent. + fn bound_payload(height: u64, parent_hash: B256) -> ExecutionPayloadV3 { + let mut payload = test_payload(0); + payload.payload_inner.payload_inner.block_number = height; + payload.payload_inner.payload_inner.parent_hash = parent_hash; + payload + } + + fn prev_block(number: u64, block_hash: B256) -> ExecutionBlock { + ExecutionBlock { + block_hash, + block_number: number, + parent_hash: B256::ZERO, + timestamp: 0, + } + } + + fn block_at(height: u64, payload: ExecutionPayloadV3) -> ConsensusBlock { + ConsensusBlock { + height: Height::new(height), + round: Round::new(0), + valid_round: Round::Nil, + proposer: Address::new([0u8; 20]), + execution_payload: payload, + validity: Validity::Valid, + signature: None, + } + } + + #[test] + fn binding_accepts_payload_at_its_height_that_extends_the_previous_block() { + let parent = B256::repeat_byte(0x11); + let payload = bound_payload(11, parent); + + check_payload_binding(&payload, Height::new(11), Some(&prev_block(10, parent))) + .expect("a payload at its height that extends the previous block is bound"); + } + + /// A fresh block forked from an older canonical ancestor: block number 5 + /// proposed at height 11. + #[test] + fn binding_rejects_block_number_below_the_consensus_height() { + let payload = bound_payload(5, B256::repeat_byte(0x44)); + + let error = check_payload_binding(&payload, Height::new(11), None) + .expect_err("block number 5 does not belong at height 11"); + + assert!( + matches!( + error, + PayloadBindingError::HeightMismatch { + expected: 11, + actual: 5 + } + ), + "got {error:?}", + ); + } + + /// The height rule holds even when the payload extends the previous block, + /// which is what a payload replayed from an older height looks like. + #[test] + fn binding_rejects_block_number_above_the_consensus_height() { + let parent = B256::repeat_byte(0x11); + let payload = bound_payload(12, parent); + + let error = check_payload_binding(&payload, Height::new(11), Some(&prev_block(10, parent))) + .expect_err("block number 12 does not belong at height 11"); + + assert!( + matches!( + error, + PayloadBindingError::HeightMismatch { + expected: 11, + actual: 12 + } + ), + "got {error:?}", + ); + } + + #[test] + fn binding_rejects_parent_that_is_not_the_previous_block() { + let expected = B256::repeat_byte(0x11); + let other = B256::repeat_byte(0x22); + let payload = bound_payload(11, other); + + let error = + check_payload_binding(&payload, Height::new(11), Some(&prev_block(10, expected))) + .expect_err("a payload that extends another block is not bound"); + + match error { + PayloadBindingError::ParentMismatch { + expected: e, + actual: a, + } => { + assert_eq!(e, expected); + assert_eq!(a, other); + } + other => panic!("got {other:?}"), + } + } + + /// Batch value sync leaves `previous_block` several heights behind, so it is + /// not the parent of the payload under test and the parent rule cannot apply. + #[test] + fn binding_skips_the_parent_rule_when_the_previous_block_lags() { + let payload = bound_payload(11, B256::repeat_byte(0x22)); + + check_payload_binding( + &payload, + Height::new(11), + Some(&prev_block(7, B256::repeat_byte(0x11))), + ) + .expect("the parent rule does not apply to a previous block that lags"); + } + + #[test] + fn binding_skips_the_parent_rule_without_a_previous_block() { + let payload = bound_payload(11, B256::repeat_byte(0x22)); + + check_payload_binding(&payload, Height::new(11), None) + .expect("the parent rule needs a previous block"); + } + + #[tokio::test] + async fn establish_block_validity_rejects_an_unbound_payload_without_asking_the_engine() { + let mut validator = MockPayloadValidator::new(); + validator.expect_validate_payload().times(0); + + let mut store = MockInvalidPayloadsRepository::new(); + store + .expect_append() + .times(1) + .withf(|ip: &InvalidPayload| { + ip.height == Height::new(11) + && ip.reason.contains("does not match consensus height") + }) + .returning(|_| Ok(())); + + let metrics = AppMetrics::default(); + let block = block_at(11, bound_payload(5, B256::ZERO)); + + let verdict = establish_block_validity(&validator, &block, None, &store, &metrics) + .await + .expect("a binding error is a verdict, not a failure"); + + assert_eq!( + verdict, + BlockVerdict::Unbound(PayloadBindingError::HeightMismatch { + expected: 11, + actual: 5, + }), + "the caller must be able to tell which rule rejected the payload", + ); + assert_eq!(verdict.validity(), Validity::Invalid); + assert_eq!(metrics.get_invalid_payloads_count(), 1); + } + + /// The parent rule reaches the caller as its own variant. A caller that answers + /// the two rules differently reads this, and the two rules blame different + /// parties. + #[tokio::test] + async fn establish_block_validity_reports_which_rule_rejected_the_payload() { + let expected = B256::repeat_byte(0xAB); + + let mut validator = MockPayloadValidator::new(); + validator.expect_validate_payload().times(0); + + let mut store = MockInvalidPayloadsRepository::new(); + store.expect_append().times(1).returning(|_| Ok(())); + + let metrics = AppMetrics::default(); + let actual = B256::repeat_byte(0xCD); + let block = block_at(11, bound_payload(11, actual)); + + let verdict = establish_block_validity( + &validator, + &block, + Some(&prev_block(10, expected)), + &store, + &metrics, + ) + .await + .expect("a binding error is a verdict, not a failure"); + + assert_eq!( + verdict, + BlockVerdict::Unbound(PayloadBindingError::ParentMismatch { expected, actual }), + ); + assert_eq!( + metrics.get_invalid_payloads_count_by_source(InvalidPayloadSource::PayloadParent), + 1, + ); + } + + #[tokio::test] + async fn establish_block_validity_asks_the_engine_about_a_bound_payload() { + let parent = B256::repeat_byte(0x11); + + let mut validator = MockPayloadValidator::new(); + validator + .expect_validate_payload() + .times(1) + .returning(|_| Ok(PayloadValidationResult::Valid)); + + let mut store = MockInvalidPayloadsRepository::new(); + store.expect_append().times(0); + + let metrics = AppMetrics::default(); + let block = block_at(11, bound_payload(11, parent)); + + let verdict = establish_block_validity( + &validator, + &block, + Some(&prev_block(10, parent)), + &store, + &metrics, + ) + .await + .expect("should succeed"); + + assert_eq!(verdict, BlockVerdict::Engine(Validity::Valid)); + assert_eq!(metrics.get_invalid_payloads_count(), 0); + } + #[tokio::test] async fn validate_consensus_block_returns_valid() { let mut validator = MockPayloadValidator::new(); @@ -646,6 +1075,22 @@ mod tests { assert_eq!(metrics.get_invalid_payloads_count(), 1); } + #[tokio::test] + async fn persist_invalid_payload_best_effort_swallows_persistence_failure() { + let mut store = MockInvalidPayloadsRepository::new(); + store + .expect_append() + .times(1) + .returning(|_| Err(std::io::Error::other("disk full"))); + + let height = Height::new(1); + let round = Round::new(0); + let proposer = Address::new([0u8; 20]); + let invalid = InvalidPayload::new_without_payload(height, round, proposer, "bad"); + + persist_invalid_payload_best_effort(&store, invalid, height, round, proposer).await; + } + #[derive(Clone, Debug)] enum Scenario { Success, diff --git a/crates/malachite-app/src/proposal_parts.rs b/crates/malachite-app/src/proposal_parts.rs index 66849d43..5b7e842a 100644 --- a/crates/malachite-app/src/proposal_parts.rs +++ b/crates/malachite-app/src/proposal_parts.rs @@ -247,21 +247,15 @@ pub async fn validate_proposal_parts( /// Resolves the expected proposer for a set of proposal parts. /// -/// When `pol_round` (proof-of-lock round) is set, the proposal is a re-stream -/// of a locked block. The proposer embedded in the parts is the original proposer -/// from `pol_round`, not the proposer for `parts.round()` (the restream round). +/// Selection uses `parts.round()`, which the parts signature covers. Restreamed parts +/// carry the round and proposer the block was stored with, so the same round resolves +/// the original proposer. pub fn resolve_expected_proposer<'a>( proposer_selector: &dyn ProposerSelector, validator_set: &'a ValidatorSet, parts: &ProposalParts, ) -> &'a Validator { - let pol_round = parts.init().pol_round; - let proposer_round = if pol_round != Round::Nil { - pol_round - } else { - parts.round() - }; - proposer_selector.select_proposer(validator_set, parts.height(), proposer_round) + proposer_selector.select_proposer(validator_set, parts.height(), parts.round()) } /// Re-assemble a [`ConsensusBlock`] from its [`ProposalParts`]. @@ -365,103 +359,159 @@ mod tests { } #[test] - fn resolve_proposer_with_pol_round_uses_original_round() { + fn resolve_proposer_with_pol_round_still_uses_parts_round() { let selector = RoundRobin; let (_keys, validator_set) = make_validator_set(3); let height = Height::new(1); - let restream_round = Round::new(2); + let parts_round = Round::new(2); let pol_round = Round::new(0); - let original_proposer = selector.select_proposer(&validator_set, height, pol_round); - let restream_proposer = selector.select_proposer(&validator_set, height, restream_round); + let parts_round_proposer = selector.select_proposer(&validator_set, height, parts_round); + let pol_round_proposer = selector.select_proposer(&validator_set, height, pol_round); // Ensure they differ so the test is meaningful assert_ne!( - original_proposer.address, restream_proposer.address, - "Test requires different proposers for pol_round and restream_round" + parts_round_proposer.address, pol_round_proposer.address, + "Test requires different proposers for parts_round and pol_round" ); - // Build parts as if restreamed: round=2, pol_round=0, proposer=original - let init = ProposalInit::new(height, restream_round, pol_round, original_proposer.address); + // Parts with non-Nil pol_round + let init = ProposalInit::new(height, parts_round, pol_round, pol_round_proposer.address); let fin = ProposalFin::new(arc_consensus_types::signing::Signature::test()); let parts = ProposalParts::new(vec![ProposalPart::Init(init), ProposalPart::Fin(fin)]).unwrap(); let expected = resolve_expected_proposer(&selector, &validator_set, &parts); - // Should resolve to the pol_round proposer, not the restream round proposer - assert_eq!(expected.address, original_proposer.address); - assert_ne!(expected.address, restream_proposer.address); + // Always resolves to the parts.round() proposer, regardless of pol_round + assert_eq!(expected.address, parts_round_proposer.address); + assert_ne!(expected.address, pol_round_proposer.address); } - /// End-to-end: restreamed proposal parts signed by the original proposer - /// pass validation when expected_proposer is resolved via pol_round. + /// Parts signed by the proposer for parts.round() pass validation. #[tokio::test] - async fn restreamed_parts_pass_validation_with_pol_round_proposer() { + async fn proposal_parts_from_correct_proposer_pass_validation() { let selector = RoundRobin; let (keys, validator_set) = make_validator_set(3); let height = Height::new(1); - let pol_round = Round::new(0); - let restream_round = Round::new(2); - - let original_proposer = selector.select_proposer(&validator_set, height, pol_round); + let round = Round::new(2); - // Find the signing key for the original proposer + let expected_proposer = selector.select_proposer(&validator_set, height, round); let signing_key = keys .iter() - .find(|k| Address::from_public_key(&k.public_key()) == original_proposer.address) + .find(|k| Address::from_public_key(&k.public_key()) == expected_proposer.address) .unwrap(); let parts = make_signed_parts( height, - restream_round, - pol_round, + round, + Round::Nil, signing_key.public_key(), signing_key, ) .await; - // Resolve via pol_round (the fix) — should match and verify - let expected = resolve_expected_proposer(&selector, &validator_set, &parts); + let resolved = resolve_expected_proposer(&selector, &validator_set, &parts); let provider = LocalSigningProvider::new(signing_key.clone()); - assert!(validate_proposal_parts(&parts, expected, &provider).await); + assert!(validate_proposal_parts(&parts, resolved, &provider).await); } - /// Restreamed parts would fail validation if we used parts_round instead - /// of pol_round to resolve the expected proposer (the old buggy behavior). + /// Parts carrying a non-Nil pol_round but signed by the pol_round proposer + /// rather than the parts.round() proposer fail validation. #[tokio::test] - async fn restreamed_parts_fail_validation_with_wrong_round_proposer() { + async fn proposal_parts_signed_by_non_proposer_fail_validation() { let selector = RoundRobin; let (keys, validator_set) = make_validator_set(3); let height = Height::new(1); let pol_round = Round::new(0); - let restream_round = Round::new(2); + let parts_round = Round::new(2); - let original_proposer = selector.select_proposer(&validator_set, height, pol_round); - let wrong_proposer = selector.select_proposer(&validator_set, height, restream_round); + let pol_round_proposer = selector.select_proposer(&validator_set, height, pol_round); + let parts_round_proposer = selector.select_proposer(&validator_set, height, parts_round); - assert_ne!(original_proposer.address, wrong_proposer.address); + assert_ne!(pol_round_proposer.address, parts_round_proposer.address); let signing_key = keys .iter() - .find(|k| Address::from_public_key(&k.public_key()) == original_proposer.address) + .find(|k| Address::from_public_key(&k.public_key()) == pol_round_proposer.address) .unwrap(); + // Parts with round=parts_round, pol_round set, signed by pol_round proposer let parts = make_signed_parts( height, - restream_round, + parts_round, pol_round, signing_key.public_key(), signing_key, ) .await; - // Using parts_round (the old bug) resolves to the wrong proposer → validation fails + // resolve_expected_proposer uses parts_round, not pol_round + let resolved = resolve_expected_proposer(&selector, &validator_set, &parts); + assert_eq!(resolved.address, parts_round_proposer.address); + + // Validation fails: parts.proposer() is pol_round proposer, but expected is parts_round proposer let provider = LocalSigningProvider::new(signing_key.clone()); - assert!(!validate_proposal_parts(&parts, wrong_proposer, &provider).await); + assert!(!validate_proposal_parts(&parts, resolved, &provider).await); + } + + /// A block carrying a non-Nil valid_round validates when restreamed: the parts + /// keep the round and proposer the block was stored with. + #[tokio::test] + async fn restreamed_parts_with_valid_round_pass_validation() { + use alloy_rpc_types_engine::ExecutionPayloadV3; + use arbitrary::{Arbitrary, Unstructured}; + + let mut u = Unstructured::new(&[0u8; 512]); + let payload = ExecutionPayloadV3::arbitrary(&mut u).unwrap(); + + let selector = RoundRobin; + let (keys, validator_set) = make_validator_set(3); + + let height = Height::new(1); + let round = Round::new(2); + let valid_round = Round::new(0); + + let round_proposer = selector.select_proposer(&validator_set, height, round); + let valid_round_proposer = selector.select_proposer(&validator_set, height, valid_round); + + assert_ne!( + round_proposer.address, valid_round_proposer.address, + "Test requires different proposers for round and valid_round" + ); + + let signing_key = keys + .iter() + .find(|k| Address::from_public_key(&k.public_key()) == round_proposer.address) + .unwrap(); + let provider = LocalSigningProvider::new(signing_key.clone()); + + let mut block = ConsensusBlock { + height, + round, + valid_round, + proposer: round_proposer.address, + validity: Validity::Valid, + execution_payload: payload, + signature: None, + }; + + // Original stream signs the block + let (_, signature) = make_proposal_parts(&provider, &block).await.unwrap(); + block.signature = Some(signature); + + // Restream reuses the stored signature, round and proposer + let (raw_parts, _) = make_proposal_parts(&provider, &block).await.unwrap(); + let parts = ProposalParts::new(raw_parts).unwrap(); + + assert_eq!(parts.init().pol_round, valid_round); + + let resolved = resolve_expected_proposer(&selector, &validator_set, &parts); + assert_eq!(resolved.address, round_proposer.address); + assert!(validate_proposal_parts(&parts, resolved, &provider).await); } /// assemble_block_from_parts must preserve pol_round as valid_round. diff --git a/crates/malachite-app/src/request.rs b/crates/malachite-app/src/request.rs index 304eab84..045eb13f 100644 --- a/crates/malachite-app/src/request.rs +++ b/crates/malachite-app/src/request.rs @@ -14,12 +14,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::time::SystemTime; +use std::time::{Instant, SystemTime}; use tokio::sync::{mpsc, oneshot}; use tracing::error; use arc_consensus_db::invalid_payloads::StoredInvalidPayloads; +use arc_consensus_db::RangeQueryResult; use arc_consensus_types::evidence::StoredMisbehaviorEvidence; use arc_consensus_types::{ signing::PublicKey, Address, ArcContext, BlockHash, CommitCertificateType, Height, Round, @@ -31,10 +32,17 @@ use arc_consensus_types::proposal_monitor::ProposalMonitor; use crate::utils::sync_state::SyncState; +/// Why an [`AppRequest`] round-trip to the consensus task failed. +/// +/// The RPC layer maps these to HTTP statuses via `request_error_to_response` +/// (`Full` → 429, `Closed`/`Recv` → 500). #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum AppRequestError { + /// The request channel is closed; the consensus task has stopped. Closed, + /// The request channel is at capacity; the caller should retry later. Full, + /// The reply channel was dropped before a response was sent. Recv, } @@ -77,15 +85,28 @@ pub struct CommitCertificateInfo { pub proposer: Address, } +/// Forward range of heights `from..=from+count-1`. +/// +/// `count` is validated against the RPC cap before the request is sent. +#[derive(Debug, PartialEq, Eq)] +pub struct HeightRangeRequest { + /// First height of the range. + pub from: Height, + /// Total number of heights, including `from`. + pub count: u64, +} + #[allow(clippy::enum_variant_names)] pub enum AppRequest { /// Retrieves a commit certificate at the given height - GetCertificate( + GetCertificate { /// The height to get the certificate for. If None, get the latest certificate. - Option, + height: Option, + /// The time the request was enqueued. + enqueued_at: Instant, /// The channel to send the certificate back on. - oneshot::Sender>, - ), + reply: oneshot::Sender>, + }, /// Retrieves misbehavior evidence at the given height GetMisbehaviorEvidence( /// The height to get the evidence for. If None, use the latest height. @@ -107,6 +128,34 @@ pub enum AppRequest { /// The channel to send the payloads back on. oneshot::Sender>, ), + /// Retrieves commit certificates for a range of heights + GetCertificateRange( + /// The range of heights to read. + HeightRangeRequest, + /// The channel to send the certificates back on. + oneshot::Sender>>, + ), + /// Retrieves misbehavior evidence for a range of heights + GetMisbehaviorEvidenceRange( + /// The range of heights to read. + HeightRangeRequest, + /// The channel to send the evidence back on. + oneshot::Sender>>, + ), + /// Retrieves proposal monitor data for a range of heights + GetProposalMonitorDataRange( + /// The range of heights to read. + HeightRangeRequest, + /// The channel to send the data back on. + oneshot::Sender>>, + ), + /// Retrieves invalid payloads for a range of heights + GetInvalidPayloadsRange( + /// The range of heights to read. + HeightRangeRequest, + /// The channel to send the payloads back on. + oneshot::Sender>>, + ), /// Get the application status GetStatus(oneshot::Sender), /// Check if the application is healthy @@ -127,7 +176,11 @@ impl AppRequest { ) -> Result, AppRequestError> { let (tx, rx) = oneshot::channel(); tx_app_req - .try_send(Self::GetCertificate(height, tx)) + .try_send(Self::GetCertificate { + height, + enqueued_at: Instant::now(), + reply: tx, + }) .inspect_err(|e| error!("Failed to send GetCertificate request to consensus: {e}"))?; let cert = rx.await.inspect_err(|e| { @@ -204,6 +257,90 @@ impl AppRequest { Ok(payloads) } + /// Request commit certificates for a range of heights. + /// + /// Returns `Ok(None)` when the store has no certificates at all. + pub async fn get_certificate_range( + range: HeightRangeRequest, + tx_app_req: &mpsc::Sender, + ) -> Result>, AppRequestError> { + let (tx, rx) = oneshot::channel(); + tx_app_req + .try_send(Self::GetCertificateRange(range, tx)) + .inspect_err(|e| { + error!("Failed to send GetCertificateRange request to consensus: {e}") + })?; + + let certs = rx.await.inspect_err(|e| { + error!("Failed to receive GetCertificateRange response from consensus: {e}") + })?; + + Ok(certs) + } + + /// Request misbehavior evidence for a range of heights. + /// + /// Returns `Ok(None)` when the store has no certificates at all. + pub async fn get_misbehavior_evidence_range( + range: HeightRangeRequest, + tx_app_req: &mpsc::Sender, + ) -> Result>, AppRequestError> { + let (tx, rx) = oneshot::channel(); + tx_app_req + .try_send(Self::GetMisbehaviorEvidenceRange(range, tx)) + .inspect_err(|e| { + error!("Failed to send GetMisbehaviorEvidenceRange request to consensus: {e}") + })?; + + let evidence = rx.await.inspect_err(|e| { + error!("Failed to receive GetMisbehaviorEvidenceRange response from consensus: {e}") + })?; + + Ok(evidence) + } + + /// Request proposal monitor data for a range of heights. + /// + /// Returns `Ok(None)` when the store has no certificates at all. + pub async fn get_proposal_monitor_data_range( + range: HeightRangeRequest, + tx_app_req: &mpsc::Sender, + ) -> Result>, AppRequestError> { + let (tx, rx) = oneshot::channel(); + tx_app_req + .try_send(Self::GetProposalMonitorDataRange(range, tx)) + .inspect_err(|e| { + error!("Failed to send GetProposalMonitorDataRange request to consensus: {e}") + })?; + + let data = rx.await.inspect_err(|e| { + error!("Failed to receive GetProposalMonitorDataRange response from consensus: {e}") + })?; + + Ok(data) + } + + /// Request invalid payloads for a range of heights. + /// + /// Returns `Ok(None)` when the store has no certificates at all. + pub async fn get_invalid_payloads_range( + range: HeightRangeRequest, + tx_app_req: &mpsc::Sender, + ) -> Result>, AppRequestError> { + let (tx, rx) = oneshot::channel(); + tx_app_req + .try_send(Self::GetInvalidPayloadsRange(range, tx)) + .inspect_err(|e| { + error!("Failed to send GetInvalidPayloadsRange request to consensus: {e}") + })?; + + let payloads = rx.await.inspect_err(|e| { + error!("Failed to receive GetInvalidPayloadsRange response from consensus: {e}") + })?; + + Ok(payloads) + } + /// Get `malachite-app`'s status. /// /// If the request fails, return an error. diff --git a/crates/malachite-app/src/rpc/handlers.rs b/crates/malachite-app/src/rpc/handlers.rs index 81b45a5c..a807a131 100644 --- a/crates/malachite-app/src/rpc/handlers.rs +++ b/crates/malachite-app/src/rpc/handlers.rs @@ -18,27 +18,111 @@ use axum::extract::Extension; use axum::extract::Query; use axum::extract::State; use axum::http::StatusCode; -use axum::response::IntoResponse; +use axum::response::{IntoResponse, Response}; use axum::Json; use serde_json::json; use malachitebft_app_channel::ConsensusRequest; use malachitebft_app_channel::NetworkRequest; -use crate::request::AppRequest; -use crate::request::TxAppReq; +use arc_consensus_types::Height; + +use crate::metrics::AppMetrics; +use crate::request::{AppRequest, AppRequestError, HeightRangeRequest, TxAppReq}; use crate::rpc::types::persistent_peer_error_to_response; use crate::rpc::types::request_error_to_response; use crate::rpc::types::RpcVersion; +use crate::rpc::types::{range_error, RpcRangeReason}; use crate::rpc::version::ApiVersion; +use crate::store::RangeQueryResult; use crate::utils::sync_state::SyncState; use super::types::{ - AddOrRemovePersistentPeerBody, GetCertificateParams, RpcAppStatus, RpcCommitCertificate, + AddOrRemovePersistentPeerBody, HeightRangeParams, RpcAppStatus, RpcCommitCertificate, RpcConsensusStateDump, RpcInvalidPayloads, RpcMisbehaviorEvidence, RpcNetworkStateDump, + RpcProposalMonitorData, }; use super::types::{TxConsensusReq, TxNetworkReq}; +/// Maximum number of heights one range query may request. Not configurable. +/// Requests above it are rejected. +const MAX_RANGE_COUNT: u64 = 1000; + +/// Validated outcome of the `height`/`count` query parameters. +#[derive(Debug, PartialEq)] +enum ResolvedQuery { + /// `count` omitted or `1`: the legacy single-height path (`None` = latest). + Single(Option), + /// `count > 1`: a validated forward range and its inclusive upper bound. + Range { range: HeightRangeRequest, to: u64 }, +} + +/// A range request rejected before reaching the consensus task. +#[derive(Debug, PartialEq)] +enum BadRange { + /// `count` was 0. + Zero, + /// `count > 1` without an explicit `height` to anchor the range. + NoAnchor, + /// `height + count - 1` overflows `u64`. + Overflow, + /// `count` exceeds `MAX_RANGE_COUNT`; carries the requested bounds for the + /// structured error body. + OverLimit { from: u64, to: u64 }, +} + +/// Resolve `height`/`count` into a [`ResolvedQuery`] or a [`BadRange`] rejection. +fn resolve_query(height: Option, count: Option) -> Result { + let count = match count { + None | Some(1) => return Ok(ResolvedQuery::Single(height)), + Some(0) => return Err(BadRange::Zero), + Some(count) => count, + }; + + // A multi-height range needs an explicit anchor. + let Some(from) = height else { + return Err(BadRange::NoAnchor); + }; + + // Overflow is checked before the cap so `to` is always exact for the + // over_limit body. count >= 2 here, so checked_sub(1) is always Some. + let Some(to) = count + .checked_sub(1) + .and_then(|offset| from.as_u64().checked_add(offset)) + else { + return Err(BadRange::Overflow); + }; + + if count > MAX_RANGE_COUNT { + return Err(BadRange::OverLimit { + from: from.as_u64(), + to, + }); + } + + Ok(ResolvedQuery::Range { + range: HeightRangeRequest { from, count }, + to, + }) +} + +fn bad_request(message: &str) -> Response { + (StatusCode::BAD_REQUEST, Json(json!({ "error": message }))).into_response() +} + +/// Render a rejected range. Argument errors get a plain `{"error": ...}` body; +/// only `over_limit` uses the structured body (it is a range-availability fact). +fn render_bad_range(bad: BadRange) -> Response { + match bad { + BadRange::Zero => bad_request("count must be at least 1"), + BadRange::NoAnchor => bad_request("height is required when count is greater than 1"), + BadRange::Overflow => bad_request("requested range exceeds the maximum u64 height"), + BadRange::OverLimit { from, to } => { + range_error(from, to, RpcRangeReason::OverLimit, Vec::new()).into_response() + } + } +} + pub(crate) async fn get_consensus_state( tx_consensus_req: State, Extension(version): Extension, @@ -72,80 +156,193 @@ pub(crate) async fn get_network_state( } pub(crate) async fn get_commit( + metrics: State, tx_app_req: State, - query: Query, + query: Query, Extension(version): Extension, -) -> impl IntoResponse { +) -> Response { + let _guard = metrics.start_rpc_request_timer("/commit"); tracing::debug!(?version, "get_commit called"); - AppRequest::get_certificate(query.height, &tx_app_req) - .await - .map_err(request_error_to_response)? - .map(|cert| Json(RpcCommitCertificate::from(cert))) - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "Certificate not found"})), - ) - }) + match resolve_query(query.height, query.count) { + Err(bad) => render_bad_range(bad), + Ok(ResolvedQuery::Single(height)) => { + let result = AppRequest::get_certificate(height, &tx_app_req).await; + if matches!(result, Err(AppRequestError::Full)) { + metrics.inc_app_request_full_count("GetCertificate"); + } + match result { + Err(e) => request_error_to_response(e).into_response(), + Ok(Some(cert)) => Json(RpcCommitCertificate::from(cert)).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "Certificate not found"})), + ) + .into_response(), + } + } + Ok(ResolvedQuery::Range { range, to }) => { + let from = range.from.as_u64(); + let result = AppRequest::get_certificate_range(range, &tx_app_req).await; + if matches!(result, Err(AppRequestError::Full)) { + metrics.inc_app_request_full_count("GetCertificate"); + } + match result { + Err(e) => request_error_to_response(e).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "Certificate not found"})), + ) + .into_response(), + Ok(Some(RangeQueryResult::Complete(items))) => Json( + items + .into_iter() + .map(RpcCommitCertificate::from) + .collect::>(), + ) + .into_response(), + Ok(Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + })) => range_error(from, to, reason.into(), failed_heights).into_response(), + } + } + } } pub(crate) async fn get_misbehavior_evidence( tx_app_req: State, - query: Query, + query: Query, Extension(version): Extension, -) -> impl IntoResponse { +) -> Response { tracing::debug!(?version, "get_misbehavior_evidence called"); - AppRequest::get_misbehavior_evidence(query.height, &tx_app_req) - .await - .map_err(request_error_to_response)? - .map(|evidence| Json(RpcMisbehaviorEvidence::from(evidence))) - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "Misbehavior evidence not found"})), - ) - }) + match resolve_query(query.height, query.count) { + Err(bad) => render_bad_range(bad), + Ok(ResolvedQuery::Single(height)) => { + match AppRequest::get_misbehavior_evidence(height, &tx_app_req).await { + Err(e) => request_error_to_response(e).into_response(), + Ok(Some(evidence)) => Json(RpcMisbehaviorEvidence::from(evidence)).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "Misbehavior evidence not found"})), + ) + .into_response(), + } + } + Ok(ResolvedQuery::Range { range, to }) => { + let from = range.from.as_u64(); + match AppRequest::get_misbehavior_evidence_range(range, &tx_app_req).await { + Err(e) => request_error_to_response(e).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "Misbehavior evidence not found"})), + ) + .into_response(), + Ok(Some(RangeQueryResult::Complete(items))) => Json( + items + .into_iter() + .map(RpcMisbehaviorEvidence::from) + .collect::>(), + ) + .into_response(), + Ok(Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + })) => range_error(from, to, reason.into(), failed_heights).into_response(), + } + } + } } pub(crate) async fn get_proposal_monitor( tx_app_req: State, - query: Query, + query: Query, Extension(version): Extension, -) -> impl IntoResponse { +) -> Response { tracing::debug!(?version, "get_proposal_monitor called"); - match AppRequest::get_proposal_monitor_data(query.height, &tx_app_req).await { - Ok(Some(data)) => { - Ok(Json(super::types::RpcProposalMonitorData::from(data)).into_response()) + match resolve_query(query.height, query.count) { + Err(bad) => render_bad_range(bad), + Ok(ResolvedQuery::Single(height)) => { + match AppRequest::get_proposal_monitor_data(height, &tx_app_req).await { + Err(e) => request_error_to_response(e).into_response(), + Ok(Some(data)) => Json(RpcProposalMonitorData::from(data)).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "Proposal monitor data not found"})), + ) + .into_response(), + } + } + Ok(ResolvedQuery::Range { range, to }) => { + let from = range.from.as_u64(); + match AppRequest::get_proposal_monitor_data_range(range, &tx_app_req).await { + Err(e) => request_error_to_response(e).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "Proposal monitor data not found"})), + ) + .into_response(), + Ok(Some(RangeQueryResult::Complete(items))) => Json( + items + .into_iter() + .map(RpcProposalMonitorData::from) + .collect::>(), + ) + .into_response(), + Ok(Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + })) => range_error(from, to, reason.into(), failed_heights).into_response(), + } } - Ok(None) => Err(( - StatusCode::NOT_FOUND, - Json(json!({"error": "Proposal monitor data not found"})), - ) - .into_response()), - Err(e) => Err(request_error_to_response(e).into_response()), } } pub(crate) async fn get_invalid_payloads( tx_app_req: State, - query: Query, + query: Query, Extension(version): Extension, -) -> impl IntoResponse { +) -> Response { tracing::debug!(?version, "get_invalid_payloads called"); - AppRequest::get_invalid_payloads(query.height, &tx_app_req) - .await - .map_err(request_error_to_response)? - .map(|payloads| Json(RpcInvalidPayloads::from(payloads))) - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "Invalid payloads not found"})), - ) - }) + match resolve_query(query.height, query.count) { + Err(bad) => render_bad_range(bad), + Ok(ResolvedQuery::Single(height)) => { + match AppRequest::get_invalid_payloads(height, &tx_app_req).await { + Err(e) => request_error_to_response(e).into_response(), + Ok(Some(payloads)) => Json(RpcInvalidPayloads::from(payloads)).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "Invalid payloads not found"})), + ) + .into_response(), + } + } + Ok(ResolvedQuery::Range { range, to }) => { + let from = range.from.as_u64(); + match AppRequest::get_invalid_payloads_range(range, &tx_app_req).await { + Err(e) => request_error_to_response(e).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "Invalid payloads not found"})), + ) + .into_response(), + Ok(Some(RangeQueryResult::Complete(items))) => Json( + items + .into_iter() + .map(RpcInvalidPayloads::from) + .collect::>(), + ) + .into_response(), + Ok(Some(RangeQueryResult::Unavailable { + reason, + failed_heights, + })) => range_error(from, to, reason.into(), failed_heights).into_response(), + } + } + } } pub(crate) async fn get_status( @@ -253,3 +450,81 @@ pub(crate) async fn remove_persistent_peer( Err(e) => request_error_to_response(e).into_response(), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn h(n: u64) -> Height { + Height::new(n) + } + + #[test] + fn resolve_query_valid() { + // count omitted queries a single height + assert_eq!(resolve_query(None, None), Ok(ResolvedQuery::Single(None))); + assert_eq!( + resolve_query(Some(h(5)), None), + Ok(ResolvedQuery::Single(Some(h(5)))) + ); + + // count = 1 queries a single height + assert_eq!( + resolve_query(None, Some(1)), + Ok(ResolvedQuery::Single(None)) + ); + assert_eq!( + resolve_query(Some(h(5)), Some(1)), + Ok(ResolvedQuery::Single(Some(h(5)))) + ); + + // count > 1 queries a range + assert_eq!( + resolve_query(Some(h(10)), Some(3)), + Ok(ResolvedQuery::Range { + range: HeightRangeRequest { + from: h(10), + count: 3, + }, + to: 12, + }) + ); + + // count = MAX_RANGE_COUNT queries a range at the cap + assert_eq!( + resolve_query(Some(h(1)), Some(MAX_RANGE_COUNT)), + Ok(ResolvedQuery::Range { + range: HeightRangeRequest { + from: h(1), + count: MAX_RANGE_COUNT, + }, + to: MAX_RANGE_COUNT, + }) + ); + } + + #[test] + fn resolve_query_bad_range() { + // count 0 + assert_eq!(resolve_query(Some(h(5)), Some(0)), Err(BadRange::Zero)); + assert_eq!(resolve_query(None, Some(0)), Err(BadRange::Zero)); + + // count > 1 without height + assert_eq!(resolve_query(None, Some(2)), Err(BadRange::NoAnchor)); + + // count > 1 with height that overflows + assert_eq!( + resolve_query(Some(h(u64::MAX)), Some(2)), + Err(BadRange::Overflow) + ); + + // count > 1 above cap + assert_eq!( + resolve_query(Some(h(10)), Some(MAX_RANGE_COUNT + 1)), + Err(BadRange::OverLimit { + from: 10, + to: 10 + MAX_RANGE_COUNT, + }) + ); + } +} diff --git a/crates/malachite-app/src/rpc/mod.rs b/crates/malachite-app/src/rpc/mod.rs index 54d8e4c2..69c9498f 100644 --- a/crates/malachite-app/src/rpc/mod.rs +++ b/crates/malachite-app/src/rpc/mod.rs @@ -27,3 +27,4 @@ mod version; pub use routes::build_router; pub use routes::serve; +pub(crate) use routes::serve_with_metrics; diff --git a/crates/malachite-app/src/rpc/routes.rs b/crates/malachite-app/src/rpc/routes.rs index 16a4f8c9..5713e265 100644 --- a/crates/malachite-app/src/rpc/routes.rs +++ b/crates/malachite-app/src/rpc/routes.rs @@ -16,20 +16,30 @@ use std::collections::BTreeMap; use std::sync::Arc; +use std::time::Duration; +use axum::extract::DefaultBodyLimit; use axum::response::IntoResponse; use axum::routing::get; use axum::{Json, Router}; use eyre::Result; use serde_json::json; use tokio::net::{TcpListener, ToSocketAddrs}; +use tower::limit::ConcurrencyLimitLayer; +use tower_http::timeout::TimeoutLayer; use tracing::{error, info}; use super::middleware::extract_version; use super::types::{EndpointInfo, RpcState, TxConsensusReq, TxNetworkReq}; use super::version::ApiVersion; +use crate::metrics::AppMetrics; use crate::request::TxAppReq; +// DoS-mitigation limits for the CL RPC server. +const RPC_MAX_BODY_SIZE: usize = 2 * 1024; +const RPC_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const RPC_MAX_CONCURRENT_REQUESTS: usize = 100; + // List of RPC routes. routes![ route!( @@ -42,36 +52,40 @@ routes![ get, "/commit", crate::rpc::handlers::get_commit, - "Get the commit certificate for a specific height", + "Get the commit certificate for a specific height or range of heights", params = { - "height (optional)" => "The height of the commit certificate to retrieve. No height returns the latest certificate." + "height (optional)" => "The height of the commit certificate to retrieve. No height returns the latest certificate.", + "count (optional)" => "Total heights to return starting at height, inclusive (forward range). Defaults to 1 (single object). When greater than 1, height is required and the response is a JSON array; capped at 1000." } ), route!( get, "/misbehavior-evidence", crate::rpc::handlers::get_misbehavior_evidence, - "Get misbehavior evidence (double votes or proposals) for a specific height", + "Get misbehavior evidence (double votes or proposals) for a specific height or range of heights", params = { - "height (optional)" => "The height of the misbehavior evidence to retrieve. No height returns the latest." + "height (optional)" => "The height of the misbehavior evidence to retrieve. No height returns the latest.", + "count (optional)" => "Total heights to return starting at height, inclusive (forward range). Defaults to 1 (single object). When greater than 1, height is required and the response is a JSON array; capped at 1000." } ), route!( get, "/proposal-monitor", crate::rpc::handlers::get_proposal_monitor, - "Get round-0 proposal monitoring data (timing and success) for a specific height", + "Get round-0 proposal monitoring data (timing and success) for a specific height or range of heights", params = { - "height (optional)" => "The height to get monitoring data for. No height returns the latest." + "height (optional)" => "The height to get monitoring data for. No height returns the latest.", + "count (optional)" => "Total heights to return starting at height, inclusive (forward range). Defaults to 1 (single object). When greater than 1, height is required and the response is a JSON array; capped at 1000." } ), route!( get, "/invalid-payloads", crate::rpc::handlers::get_invalid_payloads, - "Get invalid payloads for a specific height", + "Get invalid payloads for a specific height or range of heights", params = { - "height (optional)" => "The height of the invalid payloads to retrieve. No height returns the latest." + "height (optional)" => "The height of the invalid payloads to retrieve. No height returns the latest.", + "count (optional)" => "Total heights to return starting at height, inclusive (forward range). Defaults to 1 (single object). When greater than 1, height is required and the response is a JSON array; capped at 1000." } ), route!( @@ -105,7 +119,7 @@ routes![ "Get the current network state (peers, topics, scores)" ), route!( - post, + admin post, "/persistent-peers", crate::rpc::handlers::add_persistent_peer, "Add a persistent peer at runtime.", @@ -114,7 +128,7 @@ routes![ } ), route!( - delete, + admin delete, "/persistent-peers", crate::rpc::handlers::remove_persistent_peer, "Remove a persistent peer at runtime.", @@ -130,28 +144,81 @@ pub async fn serve( tx_consensus_req: TxConsensusReq, tx_app_req: TxAppReq, tx_network_req: TxNetworkReq, + admin_enabled: bool, ) { - if let Err(e) = inner(listen_addr, tx_consensus_req, tx_app_req, tx_network_req).await { + serve_with_metrics( + listen_addr, + tx_consensus_req, + tx_app_req, + tx_network_req, + admin_enabled, + AppMetrics::default(), + ) + .await +} + +pub(crate) async fn serve_with_metrics( + listen_addr: impl ToSocketAddrs, + tx_consensus_req: TxConsensusReq, + tx_app_req: TxAppReq, + tx_network_req: TxNetworkReq, + admin_enabled: bool, + metrics: AppMetrics, +) { + if let Err(e) = inner( + listen_addr, + tx_consensus_req, + tx_app_req, + tx_network_req, + admin_enabled, + metrics, + ) + .await + { error!("RPC server failed: {e}"); } } /// Build the RPC router with all routes and middleware /// +/// Admin routes are mounted only when `admin_enabled` is true; +/// otherwise they are neither served nor advertised in the index. +/// /// This is exposed publicly for testing purposes, allowing integration tests /// to create a server with the actual production router. pub fn build_router( tx_consensus_req: TxConsensusReq, tx_app_req: TxAppReq, tx_network_req: TxNetworkReq, + admin_enabled: bool, +) -> Router { + build_router_with_metrics( + tx_consensus_req, + tx_app_req, + tx_network_req, + admin_enabled, + AppMetrics::default(), + ) +} + +pub(crate) fn build_router_with_metrics( + tx_consensus_req: TxConsensusReq, + tx_app_req: TxAppReq, + tx_network_req: TxNetworkReq, + admin_enabled: bool, + metrics: AppMetrics, ) -> Router { let rpc_state = RpcState { tx_consensus_req, tx_app_req, tx_network_req, + metrics, }; - let routes = build_routes(); + let routes = build_routes() + .into_iter() + .filter(|route| admin_enabled || !route.admin) + .collect::>(); let mut router = Router::new(); for route in &routes { @@ -168,9 +235,15 @@ pub fn build_router( router.route("/", get(move || get_index(Arc::clone(&docs)))) }; - // Apply version extraction middleware router .layer(axum::middleware::from_fn(extract_version)) + .layer(DefaultBodyLimit::max(RPC_MAX_BODY_SIZE)) + .layer(TimeoutLayer::with_status_code( + axum::http::StatusCode::REQUEST_TIMEOUT, + RPC_REQUEST_TIMEOUT, + )) + .layer(ConcurrencyLimitLayer::new(RPC_MAX_CONCURRENT_REQUESTS)) + .layer(tower_http::compression::CompressionLayer::new()) .with_state(rpc_state) } @@ -179,8 +252,16 @@ async fn inner( tx_consensus_req: TxConsensusReq, tx_app_req: TxAppReq, tx_network_req: TxNetworkReq, + admin_enabled: bool, + metrics: AppMetrics, ) -> Result<()> { - let app = build_router(tx_consensus_req, tx_app_req, tx_network_req); + let app = build_router_with_metrics( + tx_consensus_req, + tx_app_req, + tx_network_req, + admin_enabled, + metrics, + ); let listener = TcpListener::bind(listen_addr).await?; let address = listener.local_addr()?; @@ -235,11 +316,15 @@ mod tests { use malachitebft_network::{LocalNodeInfo, PersistentPeerError, ValidatorInfo}; use super::*; - use crate::request::{AppRequest, CommitCertificateInfo, Status}; + use crate::request::{AppRequest, CommitCertificateInfo, HeightRangeRequest, Status}; use crate::rpc::types::{ RpcAppStatus, RpcCommitCertificate, RpcConsensusStateDump, RpcNetworkStateDump, }; + use crate::store::{RangeFailureReason, RangeQueryResult}; use crate::utils::sync_state::SyncState; + use arc_consensus_db::invalid_payloads::StoredInvalidPayloads; + use arc_consensus_types::evidence::StoredMisbehaviorEvidence; + use arc_consensus_types::proposal_monitor::ProposalMonitor; enum MockValue { Present, @@ -251,6 +336,22 @@ mod tests { AppGetStatus, AppGetSyncState(SyncState), AppGetCertificate(MockValue), + AppGetCertificateRange( + HeightRangeRequest, + Option>, + ), + AppGetMisbehaviorEvidenceRange( + HeightRangeRequest, + Option>, + ), + AppGetProposalMonitorDataRange( + HeightRangeRequest, + Option>, + ), + AppGetInvalidPayloadsRange( + HeightRangeRequest, + Option>, + ), ConsensusDumpState(MockValue), NetworkDumpState(MockValue), AddPersistentPeer(Result<(), PersistentPeerError>), @@ -347,7 +448,12 @@ mod tests { let _ = reply.send(state); } MockConfig::AppGetCertificate(ret) => { - let Some(AppRequest::GetCertificate(None, reply_port)) = msg else { + let Some(AppRequest::GetCertificate { + height: None, + reply: reply_port, + .. + }) = msg + else { panic!("Unexpected msg"); }; let _ = reply_port.send(match ret { @@ -355,6 +461,36 @@ mod tests { MockValue::Absent => None, }); } + MockConfig::AppGetCertificateRange(expected, ret) => { + let Some(AppRequest::GetCertificateRange(range, reply_port)) = msg else { + panic!("Unexpected msg"); + }; + assert_eq!(range, expected); + let _ = reply_port.send(ret); + } + MockConfig::AppGetMisbehaviorEvidenceRange(expected, ret) => { + let Some(AppRequest::GetMisbehaviorEvidenceRange(range, reply_port)) = msg + else { + panic!("Unexpected msg"); + }; + assert_eq!(range, expected); + let _ = reply_port.send(ret); + } + MockConfig::AppGetProposalMonitorDataRange(expected, ret) => { + let Some(AppRequest::GetProposalMonitorDataRange(range, reply_port)) = msg + else { + panic!("Unexpected msg"); + }; + assert_eq!(range, expected); + let _ = reply_port.send(ret); + } + MockConfig::AppGetInvalidPayloadsRange(expected, ret) => { + let Some(AppRequest::GetInvalidPayloadsRange(range, reply_port)) = msg else { + panic!("Unexpected msg"); + }; + assert_eq!(range, expected); + let _ = reply_port.send(ret); + } _ => panic!("Unexpected config"), } } @@ -506,6 +642,19 @@ mod tests { proposer: Address::new([0x55; 20]), } } + + fn a_commit_cert_info_at(height: u64) -> CommitCertificateInfo { + CommitCertificateInfo { + certificate: CommitCertificate::new( + Height::new(height), + Round::new(0), + ValueId::new(BlockHash::new([0xAA; 32])), + vec![], + ), + certificate_type: CommitCertificateType::Minimal, + proposer: Address::new([0x55; 20]), + } + } } async fn response_to_json(resp: Response) -> serde_json::Value { @@ -528,7 +677,8 @@ mod tests { tx_network_req: mpsc::Sender, uri: &str, ) -> (StatusCode, serde_json::Value) { - let app = build_router(tx_consensus_req, tx_app_req, tx_network_req); + // Read routes are available regardless of the admin toggle. + let app = build_router(tx_consensus_req, tx_app_req, tx_network_req, false); let req = Request::builder() .method("GET") .uri(uri) @@ -540,6 +690,144 @@ mod tests { (status, val) } + /// Like `build_router_and_request` but sets an optional `Accept-Encoding` + /// and returns the response headers and raw (possibly compressed) body, so + /// compression behavior can be asserted. + async fn build_router_and_raw_request( + tx_consensus_req: mpsc::Sender>, + tx_app_req: mpsc::Sender, + tx_network_req: mpsc::Sender, + uri: &str, + accept_encoding: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, Vec) { + let app = build_router(tx_consensus_req, tx_app_req, tx_network_req, true); + let mut builder = Request::builder().method("GET").uri(uri); + if let Some(encoding) = accept_encoding { + builder = builder.header("accept-encoding", encoding); + } + let req = builder.body(Body::empty()).unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, headers, bytes) + } + + #[tokio::test] + async fn test_response_gzip_compressed_when_requested() { + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetCertificate(MockValue::Present)); + let (status, headers, body) = build_router_and_raw_request( + tx_cons_req, + tx_app_req, + tx_nw_req, + "/commit", + Some("gzip"), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers.get("content-encoding").unwrap(), "gzip"); + + let mut decoder = flate2::read::GzDecoder::new(&body[..]); + let mut json = String::new(); + std::io::Read::read_to_string(&mut decoder, &mut json).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + let expected = + serde_json::to_value(RpcCommitCertificate::from(MockBackend::a_commit_cert_info())) + .unwrap(); + assert_eq!(val, expected); + } + + #[tokio::test] + async fn test_response_zstd_compressed_when_requested() { + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetCertificate(MockValue::Present)); + let (status, headers, body) = build_router_and_raw_request( + tx_cons_req, + tx_app_req, + tx_nw_req, + "/commit", + Some("zstd"), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers.get("content-encoding").unwrap(), "zstd"); + + let mut decoder = zstd::stream::read::Decoder::new(&body[..]).unwrap(); + let mut json = String::new(); + std::io::Read::read_to_string(&mut decoder, &mut json).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + let expected = + serde_json::to_value(RpcCommitCertificate::from(MockBackend::a_commit_cert_info())) + .unwrap(); + assert_eq!(val, expected); + } + + #[tokio::test] + async fn test_response_brotli_compressed_when_requested() { + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetCertificate(MockValue::Present)); + let (status, headers, body) = + build_router_and_raw_request(tx_cons_req, tx_app_req, tx_nw_req, "/commit", Some("br")) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers.get("content-encoding").unwrap(), "br"); + + let mut decoder = brotli::Decompressor::new(&body[..], 4096); + let mut json = String::new(); + std::io::Read::read_to_string(&mut decoder, &mut json).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + let expected = + serde_json::to_value(RpcCommitCertificate::from(MockBackend::a_commit_cert_info())) + .unwrap(); + assert_eq!(val, expected); + } + + #[tokio::test] + async fn test_response_deflate_compressed_when_requested() { + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetCertificate(MockValue::Present)); + let (status, headers, body) = build_router_and_raw_request( + tx_cons_req, + tx_app_req, + tx_nw_req, + "/commit", + Some("deflate"), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers.get("content-encoding").unwrap(), "deflate"); + + // tower-http emits zlib-wrapped deflate (RFC 1950) for + // Content-Encoding: deflate, so decode with ZlibDecoder. + let mut decoder = flate2::read::ZlibDecoder::new(&body[..]); + let mut json = String::new(); + std::io::Read::read_to_string(&mut decoder, &mut json).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + let expected = + serde_json::to_value(RpcCommitCertificate::from(MockBackend::a_commit_cert_info())) + .unwrap(); + assert_eq!(val, expected); + } + + #[tokio::test] + async fn test_response_uncompressed_without_accept_encoding() { + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetCertificate(MockValue::Present)); + let (status, headers, body) = + build_router_and_raw_request(tx_cons_req, tx_app_req, tx_nw_req, "/commit", None).await; + assert_eq!(status, StatusCode::OK); + assert!(headers.get("content-encoding").is_none()); + let val: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let expected = + serde_json::to_value(RpcCommitCertificate::from(MockBackend::a_commit_cert_info())) + .unwrap(); + assert_eq!(val, expected); + } + async fn build_router_and_request_with_body( method: &str, tx_consensus_req: mpsc::Sender>, @@ -548,7 +836,8 @@ mod tests { uri: &str, body: serde_json::Value, ) -> (StatusCode, serde_json::Value) { - let app = build_router(tx_consensus_req, tx_app_req, tx_network_req); + // The mutating persistent-peer routes exist only when admin is enabled. + let app = build_router(tx_consensus_req, tx_app_req, tx_network_req, true); let req = Request::builder() .method(method) .uri(uri) @@ -743,6 +1032,256 @@ mod tests { assert_eq!(val, expected); } + #[tokio::test] + async fn test_commit_count_one_is_single_object() { + // count=1 must take the legacy single-height path: a single object, + // never a 1-element array. The mock accepts only GetCertificate. + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetCertificate(MockValue::Present)); + let (status, val) = + build_router_and_request(tx_cons_req, tx_app_req, tx_nw_req, "/commit?count=1").await; + assert_eq!(status, StatusCode::OK); + let expected = + serde_json::to_value(RpcCommitCertificate::from(MockBackend::a_commit_cert_info())) + .unwrap(); + assert_eq!(val, expected); + assert!(!val.is_array(), "count=1 must return a single object"); + } + + #[tokio::test] + async fn test_commit_range_returns_ordered_array() { + let range = HeightRangeRequest { + from: Height::new(7), + count: 2, + }; + let reply = RangeQueryResult::Complete(vec![ + MockBackend::a_commit_cert_info_at(7), + MockBackend::a_commit_cert_info_at(8), + ]); + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetCertificateRange(range, Some(reply))); + let (status, val) = build_router_and_request( + tx_cons_req, + tx_app_req, + tx_nw_req, + "/commit?height=7&count=2", + ) + .await; + assert_eq!(status, StatusCode::OK); + let expected = serde_json::to_value(vec![ + RpcCommitCertificate::from(MockBackend::a_commit_cert_info_at(7)), + RpcCommitCertificate::from(MockBackend::a_commit_cert_info_at(8)), + ]) + .unwrap(); + assert_eq!(val, expected); + } + + #[tokio::test] + async fn test_misbehavior_evidence_range_returns_array() { + let range = HeightRangeRequest { + from: Height::new(1), + count: 3, + }; + let reply = RangeQueryResult::Complete(vec![ + StoredMisbehaviorEvidence::empty(Height::new(1)), + StoredMisbehaviorEvidence::empty(Height::new(2)), + StoredMisbehaviorEvidence::empty(Height::new(3)), + ]); + let (tx_cons_req, tx_app_req, tx_nw_req) = MockBackend::spawn_new( + MockConfig::AppGetMisbehaviorEvidenceRange(range, Some(reply)), + ); + let (status, val) = build_router_and_request( + tx_cons_req, + tx_app_req, + tx_nw_req, + "/misbehavior-evidence?height=1&count=3", + ) + .await; + assert_eq!(status, StatusCode::OK); + let heights: Vec = val + .as_array() + .expect("array body") + .iter() + .map(|e| e["height"].as_u64().unwrap()) + .collect(); + assert_eq!(heights, vec![1, 2, 3]); + } + + #[tokio::test] + async fn test_proposal_monitor_range_returns_array() { + let proposer = Address::new([0x22; 20]); + let range = HeightRangeRequest { + from: Height::new(4), + count: 2, + }; + let reply = RangeQueryResult::Complete(vec![ + ProposalMonitor::new(Height::new(4), proposer, SystemTime::UNIX_EPOCH), + ProposalMonitor::new(Height::new(5), proposer, SystemTime::UNIX_EPOCH), + ]); + let (tx_cons_req, tx_app_req, tx_nw_req) = MockBackend::spawn_new( + MockConfig::AppGetProposalMonitorDataRange(range, Some(reply)), + ); + let (status, val) = build_router_and_request( + tx_cons_req, + tx_app_req, + tx_nw_req, + "/proposal-monitor?height=4&count=2", + ) + .await; + assert_eq!(status, StatusCode::OK); + let heights: Vec = val + .as_array() + .expect("array body") + .iter() + .map(|e| e["height"].as_u64().unwrap()) + .collect(); + assert_eq!(heights, vec![4, 5]); + } + + #[tokio::test] + async fn test_invalid_payloads_range_returns_array() { + let range = HeightRangeRequest { + from: Height::new(10), + count: 2, + }; + let reply = RangeQueryResult::Complete(vec![ + StoredInvalidPayloads::empty(Height::new(10)), + StoredInvalidPayloads::empty(Height::new(11)), + ]); + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetInvalidPayloadsRange(range, Some(reply))); + let (status, val) = build_router_and_request( + tx_cons_req, + tx_app_req, + tx_nw_req, + "/invalid-payloads?height=10&count=2", + ) + .await; + assert_eq!(status, StatusCode::OK); + let heights: Vec = val + .as_array() + .expect("array body") + .iter() + .map(|e| e["height"].as_u64().unwrap()) + .collect(); + assert_eq!(heights, vec![10, 11]); + } + + #[tokio::test] + async fn test_count_zero_rejected() { + let (status, val) = build_no_backend_router_and_request("/commit?height=5&count=0").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(val, json!({"error": "count must be at least 1"})); + } + + #[tokio::test] + async fn test_count_greater_than_one_without_height_rejected() { + let (status, val) = build_no_backend_router_and_request("/commit?count=2").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + val, + json!({"error": "height is required when count is greater than 1"}) + ); + } + + #[tokio::test] + async fn test_count_over_limit_returns_structured_400() { + let (status, val) = + build_no_backend_router_and_request("/commit?height=10&count=1001").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + val, + json!({ + "error": "partial range unavailable", + "requested": {"from": 10, "to": 1010}, + "reason": "over_limit" + }) + ); + assert!( + val.get("failed_heights").is_none(), + "over_limit must omit failed_heights" + ); + } + + #[tokio::test] + async fn test_count_overflow_rejected() { + let (status, val) = + build_no_backend_router_and_request("/commit?height=18446744073709551615&count=2") + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + val, + json!({"error": "requested range exceeds the maximum u64 height"}) + ); + } + + #[tokio::test] + async fn test_count_malformed_is_query_rejection() { + // axum rejects an unparseable count with a plain-text 400 (same as + // height=abc today), so assert only the status, not a JSON body. + let (tx_c, _rc) = mpsc::channel::>(1); + let (tx_a, _ra) = mpsc::channel::(1); + let (tx_n, _rn) = mpsc::channel::(1); + let app = build_router(tx_c, tx_a, tx_n, true); + let req = Request::builder() + .method("GET") + .uri("/commit?count=abc") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_range_above_head_structured_error_body() { + let range = HeightRangeRequest { + from: Height::new(8), + count: 5, + }; + let reply = RangeQueryResult::Unavailable { + reason: RangeFailureReason::AboveCurrentHead, + failed_heights: vec![Height::new(11), Height::new(12)], + }; + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetCertificateRange(range, Some(reply))); + let (status, val) = build_router_and_request( + tx_cons_req, + tx_app_req, + tx_nw_req, + "/commit?height=8&count=5", + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + val, + json!({ + "error": "partial range unavailable", + "requested": {"from": 8, "to": 12}, + "failed_heights": [11, 12], + "reason": "above_current_head" + }) + ); + } + + #[tokio::test] + async fn test_range_empty_store_returns_legacy_404() { + let range = HeightRangeRequest { + from: Height::new(5), + count: 3, + }; + let (tx_cons_req, tx_app_req, tx_nw_req) = + MockBackend::spawn_new(MockConfig::AppGetCertificateRange(range, None)); + let (status, val) = build_router_and_request( + tx_cons_req, + tx_app_req, + tx_nw_req, + "/commit?height=5&count=3", + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(val, json!({"error": "Certificate not found"})); + } + #[tokio::test] async fn test_consensus_state_success() { let (tx_cons_req, tx_app_req, tx_nw_req) = @@ -933,4 +1472,120 @@ mod tests { assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(val, json!({"error": "Invalid multiaddr"})); } + + #[tokio::test] + async fn test_oversized_body_is_rejected() { + let (tx_dummy_cons_req, _dummy_rx_c) = mpsc::channel::>(1); + let (tx_dummy_app_req, _dummy_rx_a) = mpsc::channel::(1); + let (tx_dummy_nw_req, _dummy_rx_n) = mpsc::channel::(1); + // Admin enabled so the mutating /persistent-peers route is mounted. + let app = build_router(tx_dummy_cons_req, tx_dummy_app_req, tx_dummy_nw_req, true); + + // RPC_MAX_BODY_SIZE = 2 KiB; send 8 KiB of padding inside a JSON string. + let oversize = "a".repeat(8 * 1024); + let body = serde_json::to_vec(&json!({ "addr": oversize })).unwrap(); + assert!(body.len() > RPC_MAX_BODY_SIZE); + + let req = Request::builder() + .method("POST") + .uri("/persistent-peers") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + /// Send a request to a router built with no backend and the given admin + /// setting. Uses an invalid multiaddr body so the mutating handlers reject + /// at parse time (BAD_REQUEST) instead of blocking on an absent backend — + /// letting us tell "route mounted" (BAD_REQUEST) from "route absent" (404). + async fn status_no_backend(method: &str, uri: &str, admin_enabled: bool) -> StatusCode { + let (tx_c, _rc) = mpsc::channel::>(1); + let (tx_a, _ra) = mpsc::channel::(1); + let (tx_n, _rn) = mpsc::channel::(1); + let app = build_router(tx_c, tx_a, tx_n, admin_enabled); + let req = Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ "addr": "not-a-valid-multiaddr" })).unwrap(), + )) + .unwrap(); + app.oneshot(req).await.unwrap().status() + } + + async fn index_json(admin_enabled: bool) -> serde_json::Value { + let (tx_c, _rc) = mpsc::channel::>(1); + let (tx_a, _ra) = mpsc::channel::(1); + let (tx_n, _rn) = mpsc::channel::(1); + let app = build_router(tx_c, tx_a, tx_n, admin_enabled); + let req = Request::builder() + .method("GET") + .uri("/") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + response_to_json(resp).await + } + + #[tokio::test] + async fn test_persistent_peer_mutation_routes_absent_without_admin() { + assert_eq!( + status_no_backend("POST", "/persistent-peers", false).await, + StatusCode::NOT_FOUND + ); + assert_eq!( + status_no_backend("DELETE", "/persistent-peers", false).await, + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn test_persistent_peer_mutation_routes_present_with_admin() { + // Route mounted: the request reaches the handler, which rejects the + // invalid multiaddr (BAD_REQUEST) rather than returning 404. + assert_eq!( + status_no_backend("POST", "/persistent-peers", true).await, + StatusCode::BAD_REQUEST + ); + assert_eq!( + status_no_backend("DELETE", "/persistent-peers", true).await, + StatusCode::BAD_REQUEST + ); + } + + #[tokio::test] + async fn test_read_route_available_without_admin() { + // A read route stays reachable in the default (admin-off) configuration. + assert_eq!( + status_no_backend("GET", "/version", false).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn test_index_excludes_admin_routes_without_admin() { + let val = index_json(false).await; + let endpoints = &val["endpoints"]; + assert!( + endpoints.get("GET /status").is_some(), + "read routes must still be advertised" + ); + assert!( + endpoints.get("POST /persistent-peers").is_none(), + "admin routes must not be advertised when admin is off" + ); + assert!(endpoints.get("DELETE /persistent-peers").is_none()); + } + + #[tokio::test] + async fn test_index_includes_admin_routes_with_admin() { + let val = index_json(true).await; + let endpoints = &val["endpoints"]; + assert!(endpoints.get("POST /persistent-peers").is_some()); + assert!(endpoints.get("DELETE /persistent-peers").is_some()); + } } diff --git a/crates/malachite-app/src/rpc/types.rs b/crates/malachite-app/src/rpc/types.rs index be05ab2a..c89d5ac6 100644 --- a/crates/malachite-app/src/rpc/types.rs +++ b/crates/malachite-app/src/rpc/types.rs @@ -28,7 +28,7 @@ use tokio::sync::mpsc::Sender; use tracing::error; use arc_consensus_types::{ - commit_http::HttpCommitSignature, + commit_http::HttpCommitCertificate, evidence::{DoubleProposal, DoubleVote, StoredMisbehaviorEvidence, ValidatorEvidence}, signing::PublicKey, Address, ArcContext, BlockHash, Height, Validator, ValidatorSet, Value, ValueId, @@ -45,11 +45,13 @@ use malachitebft_core_state_machine::state::{RoundValue, State as MState}; use malachitebft_core_types::{NilOrVal, VoteType}; use malachitebft_network::PersistentPeerError; +use crate::metrics::AppMetrics; use crate::request::{AppRequestError, CommitCertificateInfo, Status, TxAppReq}; use crate::utils::sync_state::SyncState; use alloy_rpc_types_engine::ExecutionPayloadV3; use arc_consensus_db::invalid_payloads::{InvalidPayload, StoredInvalidPayloads}; +use arc_consensus_db::RangeFailureReason; use arc_consensus_types::proposal_monitor::ProposalMonitor; pub(crate) type TxConsensusReq = Sender>; @@ -60,6 +62,7 @@ pub(crate) struct RpcState { pub tx_consensus_req: TxConsensusReq, pub tx_app_req: TxAppReq, pub tx_network_req: TxNetworkReq, + pub metrics: AppMetrics, } pub(crate) struct RouteDef { @@ -67,6 +70,8 @@ pub(crate) struct RouteDef { pub path: &'static str, pub handler: fn() -> axum::routing::MethodRouter, pub doc: EndpointInfo, + /// Admin routes. Mounted only when the admin RPC is enabled. + pub admin: bool, } macro_rules! method_str { @@ -90,13 +95,23 @@ macro_rules! routes { } macro_rules! route { - ($method:ident, $path:expr, $handler_fn:path, $desc:expr) => { - route!(@build $method, $path, $handler_fn, $desc, None) + // Read-only routes must be GET. A non-GET route without the `admin` marker + // is rejected by the catch-all arm below, so a state-mutating route cannot + // be registered as publicly reachable by forgetting to tag it. + (get, $path:expr, $handler_fn:path, $desc:expr) => { + route!(@build get, $path, $handler_fn, $desc, None, false) }; - ($method:ident, $path:expr, $handler_fn:path, $desc:expr, params = { $( $pkey:expr => $pval:expr ),* $(,)? }) => { - route!(@build $method, $path, $handler_fn, $desc, Some(::std::collections::BTreeMap::from([ $( ($pkey, $pval) ),* ]))) + (get, $path:expr, $handler_fn:path, $desc:expr, params = { $( $pkey:expr => $pval:expr ),* $(,)? }) => { + route!(@build get, $path, $handler_fn, $desc, Some(::std::collections::BTreeMap::from([ $( ($pkey, $pval) ),* ])), false) }; - (@build $method:ident, $path:expr, $handler_fn:path, $desc:expr, $params:expr) => { + // Admin routes use any method but must opt in explicitly via `admin`. + (admin $method:ident, $path:expr, $handler_fn:path, $desc:expr) => { + route!(@build $method, $path, $handler_fn, $desc, None, true) + }; + (admin $method:ident, $path:expr, $handler_fn:path, $desc:expr, params = { $( $pkey:expr => $pval:expr ),* $(,)? }) => { + route!(@build $method, $path, $handler_fn, $desc, Some(::std::collections::BTreeMap::from([ $( ($pkey, $pval) ),* ])), true) + }; + (@build $method:ident, $path:expr, $handler_fn:path, $desc:expr, $params:expr, $admin:expr) => { crate::rpc::types::RouteDef { method: method_str!($method), path: $path, @@ -105,8 +120,20 @@ macro_rules! route { desc: $desc, params: $params, }, + admin: $admin, } }; + // Any non-admin route that is not GET is almost certainly an oversight: it + // would expose a non-read-only endpoint without the admin gate. Reject it. + ($method:ident, $($rest:tt)*) => { + compile_error!(concat!( + "non-admin RPC route `", + stringify!($method), + "` must be GET; mark state-mutating routes with the `admin` keyword (e.g. `admin ", + stringify!($method), + "`)" + )) + }; } #[derive(serde::Serialize)] @@ -243,14 +270,14 @@ pub(crate) fn build_network_validator_set(vs: &[(String, u64)]) -> RpcNwValidato } } +/// Query parameters shared by the height-range observability endpoints +/// (`/commit`, `/misbehavior-evidence`, `/proposal-monitor`, +/// `/invalid-payloads`): an optional anchor `height` and an optional `count` +/// of consecutive heights to return. #[derive(serde::Deserialize)] -pub(crate) struct GetCertificateParams { - pub height: Option, -} - -#[derive(serde::Deserialize)] -pub(crate) struct GetProposalMonitorParams { +pub(crate) struct HeightRangeParams { pub height: Option, + pub count: Option, } #[derive(serde::Deserialize)] @@ -356,10 +383,8 @@ struct RpcPendingProposalParts { #[derive(Serialize)] pub(crate) struct RpcCommitCertificate { - height: u64, - round: i64, - block_hash: ValueId, - signatures: Vec, + #[serde(flatten)] + inner: HttpCommitCertificate, proposer: Address, extended: bool, } @@ -367,15 +392,17 @@ pub(crate) struct RpcCommitCertificate { impl From for RpcCommitCertificate { fn from(info: CommitCertificateInfo) -> Self { RpcCommitCertificate { - height: info.certificate.height.as_u64(), - round: info.certificate.round.as_i64(), - block_hash: info.certificate.value_id, - signatures: info - .certificate - .commit_signatures - .into_iter() - .map(Into::into) - .collect(), + inner: HttpCommitCertificate { + height: info.certificate.height.as_u64(), + round: info.certificate.round.as_i64(), + block_hash: info.certificate.value_id, + signatures: info + .certificate + .commit_signatures + .into_iter() + .map(Into::into) + .collect(), + }, proposer: info.proposer, extended: info.certificate_type.is_extended(), } @@ -632,6 +659,72 @@ impl From for RequestError { } } +/// Why a range query could not be served, as rendered on the wire. +/// +/// `OverLimit` is RPC-layer policy (the request never reaches the store); the +/// other four map one-to-one from the store's [`RangeFailureReason`]. +#[derive(Serialize, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RpcRangeReason { + AboveCurrentHead, + Pruned, + NotRecorded, + Internal, + OverLimit, +} + +impl From for RpcRangeReason { + fn from(reason: RangeFailureReason) -> Self { + match reason { + RangeFailureReason::AboveCurrentHead => Self::AboveCurrentHead, + RangeFailureReason::Pruned => Self::Pruned, + RangeFailureReason::NotRecorded => Self::NotRecorded, + RangeFailureReason::Internal => Self::Internal, + } + } +} + +/// The inclusive height range the client asked for (`from..=to`), echoed back +/// in the error so a failed poll can be retried without re-deriving the bounds. +#[derive(Serialize)] +struct RequestedRange { + from: u64, + to: u64, +} + +/// Structured `400` body for a range query that cannot be fully served. +/// +/// Uniform across all four endpoints so the polling client handles failures +/// identically and retries only `failed_heights`. +#[derive(Serialize)] +pub(crate) struct RangeError { + error: &'static str, + requested: RequestedRange, + #[serde(skip_serializing_if = "Vec::is_empty")] + failed_heights: Vec, + reason: RpcRangeReason, +} + +/// Build the structured `400` for an unavailable or over-limit range. +/// +/// `failed_heights` is empty for `over_limit` (the range is never evaluated). +pub(crate) fn range_error( + from: u64, + to: u64, + reason: RpcRangeReason, + failed_heights: Vec, +) -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(RangeError { + error: "partial range unavailable", + requested: RequestedRange { from, to }, + failed_heights: failed_heights.iter().map(Height::as_u64).collect(), + reason, + }), + ) +} + pub(crate) fn request_error_to_response( err: impl Into, ) -> (StatusCode, Json) { @@ -805,6 +898,58 @@ impl From for RpcInvalidPayload { } } +#[cfg(test)] +mod tests_rendering { + use super::*; + use arc_consensus_types::signing::PrivateKey; + use malachitebft_core_types::Round; + use rand::rngs::OsRng; + use std::time::SystemTime; + + #[test] + fn rpc_validator_renders_public_key_as_0x_lowercase_hex() { + let private_key = PrivateKey::generate(OsRng); + let public_key = private_key.public_key(); + let key_bytes = public_key.as_bytes().to_vec(); + let validator = Validator::new(public_key, 100); + + let rpc = RpcValidator::from(&validator); + + // Renders as `0x` + lowercase hex (hex::encode is lowercase by construction). + let expected = format!("0x{}", hex::encode(&key_bytes)); + assert_eq!(rpc.public_key_hex, expected); + } + + #[test] + fn rpc_app_status_renders_public_key_as_0x_lowercase_hex() { + let private_key = PrivateKey::generate(OsRng); + let public_key = private_key.public_key(); + let key_bytes = public_key.as_bytes().to_vec(); + + let status = Status { + height: Height::new(1), + round: Round::new(0), + address: Address::from_public_key(&public_key), + public_key, + proposer: None, + height_start_time: SystemTime::UNIX_EPOCH, + prev_payload_hash: None, + db_latest_height: Height::default(), + db_earliest_height: Height::default(), + undecided_blocks_count: 0, + pending_proposal_parts: Vec::new(), + validator_set: ValidatorSet::default(), + sync_state: SyncState::InSync, + }; + + let rpc = RpcAppStatus::from(status); + + // Renders as `0x` + lowercase hex (hex::encode is lowercase by construction). + let expected = format!("0x{}", hex::encode(&key_bytes)); + assert_eq!(rpc.public_key, expected); + } +} + #[cfg(test)] mod tests_network { use super::*; @@ -1104,3 +1249,60 @@ mod tests_misbehavior_evidence { assert_eq!(rpc.validators[1].double_proposals.len(), 1); } } + +#[cfg(test)] +mod tests_rpc_commit_certificate { + use super::*; + use arc_consensus_types::commit_http::HttpCommitSignature; + + /// Pins the `GET /commit` response shape: the 4 common fields come from + /// `HttpCommitCertificate` via `#[serde(flatten)]`, and `proposer` / + /// `extended` are appended at the top level (no `inner` wrapper). + #[test] + fn serializes_with_flat_fields() { + let cert = RpcCommitCertificate { + inner: HttpCommitCertificate { + height: 42, + round: 3, + block_hash: ValueId::new(BlockHash::ZERO), + signatures: vec![HttpCommitSignature { + address: Address::default(), + signature: vec![0u8; 64], + }], + }, + proposer: Address::default(), + extended: true, + }; + let value = serde_json::to_value(&cert).unwrap(); + let obj = value.as_object().unwrap(); + + let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "block_hash", + "extended", + "height", + "proposer", + "round", + "signatures" + ] + ); + assert!( + !obj.contains_key("inner"), + "flatten must inline inner fields" + ); + + let sigs = obj["signatures"].as_array().unwrap(); + assert_eq!(sigs.len(), 1); + let mut sig_keys: Vec<&str> = sigs[0] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + sig_keys.sort(); + assert_eq!(sig_keys, vec!["address", "signature"]); + } +} diff --git a/crates/malachite-app/src/rpc_sync/network.rs b/crates/malachite-app/src/rpc_sync/network.rs index 598650d6..01f07efc 100644 --- a/crates/malachite-app/src/rpc_sync/network.rs +++ b/crates/malachite-app/src/rpc_sync/network.rs @@ -59,6 +59,7 @@ use malachitebft_app_channel::app::engine::network::{ }; use malachitebft_app_channel::app::engine::util::output_port::OutputPort; use malachitebft_app_channel::NetworkMsg; +use malachitebft_core_types::{ExtendedCommitCertificate, VoteExtensions}; use malachitebft_peer::PeerId; use malachitebft_sync::{OutboundRequestId, RawDecidedValue, Response, ValueResponse}; @@ -161,7 +162,10 @@ async fn fetch_range( .into_iter() .map(|block| RawDecidedValue { value_bytes: block.value_bytes, - certificate: block.certificate, + certificate: ExtendedCommitCertificate::from_commit_certificate_and_extensions( + block.certificate, + VoteExtensions::default(), + ), }) .collect(); diff --git a/crates/malachite-app/src/state.rs b/crates/malachite-app/src/state.rs index ea3d239f..98a7b199 100644 --- a/crates/malachite-app/src/state.rs +++ b/crates/malachite-app/src/state.rs @@ -21,33 +21,107 @@ use std::collections::HashMap; use std::time::{Duration, Instant, SystemTime}; use eyre::Context as _; +use tracing::warn; use malachitebft_app_channel::app::streaming::StreamId; use malachitebft_app_channel::app::types::core::Round; +use malachitebft_app_channel::app::types::ProposedValue; use crate::streaming; use arc_consensus_types::{ - Address, AlloyAddress, ArcContext, BlockHash, ChainId, Config, ConsensusParams, ConsensusSpec, - Height, NetworkId, ValidatorSet, + signing::PublicKey, Address, AlloyAddress, ArcContext, BlockHash, ChainId, Config, + ConsensusParams, ConsensusSpec, Height, NetworkId, ValidatorSet, ValueId, }; use arc_eth_engine::json_structures::ExecutionBlock; use arc_eth_engine::persistence_meter::{NoopPersistenceMeter, PersistenceMeter}; use arc_signer::ArcSigningProvider; use malachitebft_core_types::HeightParams; -use crate::block::ConsensusBlock; use crate::env_config::EnvConfig; use crate::metrics::app::AppMetrics; use crate::node::ConsensusIdentity; use crate::request::Status; use crate::stats::Stats; -use crate::store::repositories::UndecidedBlocksRepository; use crate::store::Store; use crate::streaming::PartStreamsMap; use crate::utils::sync_state::SyncState; use arc_consensus_types::proposal_monitor::ProposalMonitor; +/// A snapshot of the volatile consensus state fields needed to serve status queries. +/// +/// This is published via a `tokio::sync::watch` channel after each consensus message, +/// so that the app-request task can read it without blocking the consensus loop. +/// +/// The snapshot is eventually consistent with `State`: it is republished after the +/// consensus loop finishes handling a message, so a reader may observe a snapshot +/// that is up to one consensus-message stale. Fields derived from it (e.g. the +/// `(height, round)` used to query `get_undecided_blocks`) inherit that staleness. +/// This is acceptable for the `/status` RPC — strict freshness would require +/// re-coupling the request handler to the consensus loop. +#[derive(Clone, Debug, PartialEq)] +pub struct StatusSnapshot { + pub height: Height, + pub round: Round, + pub proposer: Option
, + pub address: Address, + pub public_key: PublicKey, + pub previous_block: Option, + pub validator_set: ValidatorSet, + pub sync_state: SyncState, +} + +impl StatusSnapshot { + /// Build the full `Status` response from this snapshot plus live DB/stats queries. + pub async fn get_status(&self, store: &Store, stats: &Stats) -> eyre::Result { + let undecided_blocks_count = store + .get_undecided_blocks(self.height, self.round) + .await + .wrap_err_with(|| { + format!( + "Failed to get undecided blocks for height {} and round {} from the state", + self.height, self.round, + ) + })? + .len(); + + let db_latest_height = store + .max_height() + .await + .wrap_err("Failed to get the latest height from the state")? + .unwrap_or_default(); + + let db_earliest_height = store + .min_height() + .await + .wrap_err("Failed to get earliest height from the state")? + .unwrap_or_default(); + + let pending_proposal_parts = store + .get_pending_proposal_parts_counts() + .await + .wrap_err("Failed to get pending proposal parts counts from the state")?; + + Ok(Status { + height: self.height, + round: self.round, + address: self.address, + public_key: self.public_key, + proposer: self.proposer, + // elapsed() is always <= time since epoch, so this won't underflow + #[allow(clippy::arithmetic_side_effects)] + height_start_time: SystemTime::now() - stats.height_started().elapsed(), + prev_payload_hash: self.previous_block.map(|b| b.block_hash), + db_latest_height, + db_earliest_height, + undecided_blocks_count, + pending_proposal_parts, + validator_set: self.validator_set.clone(), + sync_state: self.sync_state, + }) + } +} + /// Information needed to start the next height after a decision is reached. #[derive(Debug)] pub struct NextHeightInfo { @@ -86,6 +160,131 @@ pub enum Decision { Failure(eyre::Report), } +/// Proposal-monitor data captured for a height before its monitor exists. +/// +/// Proposal events can reach the application before round 0 of the +/// corresponding height has started locally: +/// * a `ProcessSyncedValue` is processed for a height the node has not +/// yet entered (note: today this branch is not reachable under the +/// current sync protocol, but the stash is kept for robustness); +/// * gossip-delivered proposal parts complete a payload for height `H+1` +/// while the node is still wrapping up height `H`. +/// +/// Entries are consumed by [`State::init_proposal_monitor`] when round 0 of +/// the matching height starts. +#[derive(Clone, Debug)] +enum EarlyArrival { + /// A synced value was processed for this height. + Synced(SystemTime), + /// Proposal parts for this height were buffered as pending before the + /// monitor existed. + PendingPartsTime(SystemTime), +} + +/// Pre-populate a freshly-built monitor from a stashed early arrival. +fn apply_early_arrival(monitor: &mut ProposalMonitor, early: EarlyArrival) { + match early { + EarlyArrival::Synced(t) => { + monitor.proposal_receive_time = Some(t); + monitor.mark_synced(); + } + // `synced` stays false and `value_id` stays `None`: the value id is + // filled in by `attach_value_id_to_monitor` at reassembly. + EarlyArrival::PendingPartsTime(t) => monitor.proposal_receive_time = Some(t), + } +} + +/// Build the round-0 monitor for `height`, consuming any early arrival stashed +/// for it (which yields the negative delay). +fn build_monitor( + height: Height, + proposer: Address, + start_time: SystemTime, + early: Option, +) -> ProposalMonitor { + let mut monitor = ProposalMonitor::new(height, proposer, start_time); + if let Some(early) = early { + apply_early_arrival(&mut monitor, early); + } + monitor +} + +/// Stash an early arrival for `height`, keeping the first arrival except that a +/// pending-parts arrival supersedes a (dormant) synced one. +fn stash_early_arrival( + early_arrivals: &mut HashMap, + height: Height, + arrival: EarlyArrival, +) { + let supersedes = |existing: &EarlyArrival| { + matches!( + (&arrival, existing), + (EarlyArrival::PendingPartsTime(_), EarlyArrival::Synced(_)) + ) + }; + if early_arrivals.get(&height).is_none_or(supersedes) { + early_arrivals.insert(height, arrival); + } +} + +/// Record a synced value's receive time: directly on the monitor when it exists +/// for `height` (unless a normal proposal already set it), otherwise stashed. +fn record_synced( + monitor: &mut Option, + early_arrivals: &mut HashMap, + height: Height, + now: SystemTime, +) { + match monitor { + Some(m) if m.height == height => { + if m.proposal_receive_time.is_some() { + // Normal proposals take precedence over synced values + return; + } + m.proposal_receive_time = Some(now); + m.mark_synced(); + } + _ => stash_early_arrival(early_arrivals, height, EarlyArrival::Synced(now)), + } +} + +/// Attach an assembled value id to the monitor, if it is the round-0 monitor +/// for `height`. A missing or mismatched monitor is an invariant violation +/// here (the monitor is created at round-0 start, before this runs), so it is +/// logged. +fn attach_value_id_to_monitor( + monitor: &mut Option, + height: Height, + value_id: ValueId, +) { + let Some(m) = monitor.as_mut() else { + warn!(%height, "attach_assembled_value_id: no proposal monitor present"); + return; + }; + if m.height != height { + warn!(%height, monitor.height = %m.height, "attach_assembled_value_id: monitor height mismatch"); + return; + } + m.attach_assembled_value_id(value_id); +} + +/// Attach the value id of every valid round-0 proposal to the monitor. No-op +/// off round 0; engine-invalid proposals are skipped (they can never be the +/// decided value). +pub(crate) fn attach_valid_proposal_value_ids( + monitor: &mut Option, + height: Height, + round: Round, + proposals: &[ProposedValue], +) { + if round.as_i64() != 0 { + return; + } + for p in proposals.iter().filter(|p| p.validity.is_valid()) { + attach_value_id_to_monitor(monitor, height, p.value.id()); + } +} + /// Represents the internal state of the application node /// Contains information about current height, round, proposals and blocks pub struct State { @@ -134,8 +333,10 @@ pub struct State { /// Monitor for tracking round-0 proposal timing and success pub proposal_monitor: Option, - /// Timestamps of heights that received a synced value via ProcessSyncedValue. - synced_heights: HashMap, + /// Proposal events recorded for heights before their proposal monitor was + /// initialized. Entries are consumed by [`Self::init_proposal_monitor`] + /// when round 0 of the matching height starts. + early_arrivals: HashMap, /// Meters EL block persistence to apply backpressure during sync catch-up. persistence_meter: Box, @@ -203,7 +404,7 @@ impl State { previous_block: None, consensus_params: ConsensusParams::default(), proposal_monitor: None, - synced_heights: HashMap::new(), + early_arrivals: HashMap::new(), persistence_meter: Box::new(NoopPersistenceMeter), spec, metrics, @@ -329,47 +530,38 @@ impl State { self.network_id } - /// Initialize the proposal monitor for round 0. + /// Initialize the proposal monitor for round 0, consuming any + /// [`EarlyArrival`] stashed for this height (which yields the negative delay). fn init_proposal_monitor(&mut self, round: Round, proposer: Address) { assert_eq!(round.as_i64(), 0); let height = self.current_height; - - let start_time = SystemTime::now(); - let mut monitor = ProposalMonitor::new(height, proposer, start_time); - - // If an early `ProcessSyncedValue` event was processed for this height, - // use the associated recorded timestamp as proposal receive time. - if let Some(synced_time) = self.synced_heights.remove(&height) { - monitor.proposal_receive_time = Some(synced_time); - monitor.mark_synced(); - } - - self.proposal_monitor = Some(monitor); + let early = self.early_arrivals.remove(&height); + self.proposal_monitor = Some(build_monitor(height, proposer, SystemTime::now(), early)); } /// Mark a height as having received a synced value, storing the receive time. pub fn mark_height_synced(&mut self, height: Height) { - let now = SystemTime::now(); + record_synced( + &mut self.proposal_monitor, + &mut self.early_arrivals, + height, + SystemTime::now(), + ); + } - let Some(monitor) = &mut self.proposal_monitor else { - self.synced_heights.insert(height, now); - return; - }; - if monitor.height != height { - self.synced_heights.insert(height, now); - return; - } - if monitor.proposal_receive_time.is_some() { - // Normal proposals take precedence over synced values - return; - } - monitor.proposal_receive_time = Some(now); - monitor.mark_synced(); + /// Record the receive time of proposal parts buffered as pending for a + /// future `height`. The value id is attached later, at reassembly. + pub fn record_early_pending_parts_time(&mut self, height: Height) { + stash_early_arrival( + &mut self.early_arrivals, + height, + EarlyArrival::PendingPartsTime(SystemTime::now()), + ); } - /// Clean up synced height tracking for past heights. - pub fn cleanup_synced_heights(&mut self, current_height: Height) { - self.synced_heights.retain(|h, _| *h >= current_height); + /// Drop early-arrival entries for heights below `current_height`. + pub fn cleanup_early_arrivals(&mut self, current_height: Height) { + self.early_arrivals.retain(|h, _| *h >= current_height); } /// Maximum number of pending proposals allowed @@ -386,69 +578,18 @@ impl State { limit } - /// Return important current information. - pub async fn get_status(&self) -> eyre::Result { - let undecided_blocks_count = self - .get_undecided_blocks(self.current_height, self.current_round) - .await - .wrap_err_with(|| { - format!( - "Failed to get undecided blocks for height {} and round {} from the state", - self.current_height, self.current_round, - ) - })? - .len(); - - let pending_proposal_parts = self - .store - .get_pending_proposal_parts_counts() - .await - .wrap_err("Failed to get pending proposal parts counts from the state")?; - - Ok(Status { + /// Create a snapshot of the volatile consensus state fields. + pub fn status_snapshot(&self) -> StatusSnapshot { + StatusSnapshot { height: self.current_height, round: self.current_round, + proposer: self.current_proposer, address: self.address(), public_key: *self.identity.public_key(), - proposer: self.current_proposer, - // elapsed() is always <= time since epoch, so this won't underflow - #[allow(clippy::arithmetic_side_effects)] - height_start_time: SystemTime::now() - self.stats.height_started().elapsed(), - prev_payload_hash: self.previous_block.map(|b| b.block_hash), - db_latest_height: self - .store() - .max_height() - .await - .wrap_err("Failed to get the latest height from the state")? - .unwrap_or_default(), - db_earliest_height: self - .store() - .min_height() - .await - .wrap_err("Failed to get earliest height from the state")? - .unwrap_or_default(), - undecided_blocks_count, - pending_proposal_parts, - validator_set: self.validator_set().to_owned(), + previous_block: self.previous_block, + validator_set: self.validator_set().clone(), sync_state: self.sync_state, - }) - } - - /// Return unit type. Used to check the app is active. - pub fn get_health(&self) {} - - /// Retrieves all undecided blocks at the given height and round. - pub async fn get_undecided_blocks( - &self, - height: Height, - round: Round, - ) -> eyre::Result> { - self.store - .get_by_round(height, round) - .await - .wrap_err_with(|| { - format!("Failed to get undecided blocks for height {height} and round {round} from the database") - }) + } } /// Move to the next height, updating the previous block, validator set, and consensus params. @@ -469,8 +610,8 @@ impl State { // Update the consensus params for the next height self.set_consensus_params(info.consensus_params); - // Clean up synced heights tracking for past heights - self.cleanup_synced_heights(info.next_height); + // Clean up early-arrival entries for past heights + self.cleanup_early_arrivals(info.next_height); } /// Build the next stream ID for a proposal at the given `height` and `round`. @@ -537,3 +678,294 @@ impl State { self.store.savepoint(); } } + +#[cfg(test)] +mod tests { + use super::*; + use arc_consensus_types::{Value, B256}; + use malachitebft_app_channel::app::types::core::Validity; + + fn test_address() -> Address { + Address::new([0x42; 20]) + } + + fn test_value_id(seed: u8) -> ValueId { + ValueId::new(B256::repeat_byte(seed)) + } + + fn test_proposed_value( + height: Height, + round: Round, + proposer: Address, + validity: Validity, + seed: u8, + ) -> ProposedValue { + ProposedValue { + height, + round, + valid_round: Round::Nil, + proposer, + value: Value::new(BlockHash::repeat_byte(seed)), + validity, + } + } + + #[test] + fn apply_early_arrival_synced_marks_synced_and_sets_time() { + let start = SystemTime::now(); + let mut monitor = ProposalMonitor::new(Height::new(1), test_address(), start); + + let earlier = start - Duration::from_millis(150); + apply_early_arrival(&mut monitor, EarlyArrival::Synced(earlier)); + + assert_eq!(monitor.proposal_receive_time, Some(earlier)); + assert!(monitor.synced); + assert!(monitor.value_id.is_none()); + } + + #[test] + fn apply_early_arrival_pending_parts_sets_time_leaves_synced_false_and_value_unset() { + let start = SystemTime::now(); + let mut monitor = ProposalMonitor::new(Height::new(1), test_address(), start); + + let earlier = start - Duration::from_millis(150); + apply_early_arrival(&mut monitor, EarlyArrival::PendingPartsTime(earlier)); + + assert_eq!(monitor.proposal_receive_time, Some(earlier)); + assert!(!monitor.synced); + // value id is filled in later, by attach_assembled_value_id. + assert!(monitor.value_id.is_none()); + } + + #[test] + fn apply_early_arrival_pending_parts_yields_negative_delay_when_earlier_than_start() { + // The proposal arrived *before* round-0 start, so the delay computed downstream + // from `(proposal_receive_time - start_time)` must be negative. + let start = SystemTime::now(); + let mut monitor = ProposalMonitor::new(Height::new(7), test_address(), start); + + let earlier = start - Duration::from_millis(200); + apply_early_arrival(&mut monitor, EarlyArrival::PendingPartsTime(earlier)); + + let receive = monitor.proposal_receive_time.expect("set above"); + assert!( + receive < monitor.start_time, + "proposal_receive_time should precede start_time" + ); + } + + #[test] + fn pending_parts_then_attach_then_decide_marks_successful() { + // End-to-end of the two-phase pending-parts recording. + let start = SystemTime::now(); + let mut monitor = ProposalMonitor::new(Height::new(7), test_address(), start); + + // Phase 1: receive time applied at round-0 start (negative delay). + apply_early_arrival( + &mut monitor, + EarlyArrival::PendingPartsTime(start - Duration::from_millis(100)), + ); + assert!(monitor.value_id.is_none()); + + // Phase 2: value id attached after reassembly. + let value = test_value_id(0xDE); + monitor.attach_assembled_value_id(value); + assert_eq!(monitor.value_id, Some(value)); + assert!(!monitor.synced); + + monitor.mark_decided(&value); + assert!(monitor.successful.is_successful()); + } + + #[test] + fn build_monitor_without_early_arrival_is_plain() { + let m = build_monitor(Height::new(3), test_address(), SystemTime::now(), None); + assert_eq!(m.height, Height::new(3)); + assert!(m.proposal_receive_time.is_none()); + assert!(m.value_id.is_none()); + assert!(!m.synced); + } + + #[test] + fn build_monitor_consumes_pending_parts_arrival() { + let start = SystemTime::now(); + let earlier = start - Duration::from_millis(90); + let m = build_monitor( + Height::new(3), + test_address(), + start, + Some(EarlyArrival::PendingPartsTime(earlier)), + ); + assert_eq!(m.proposal_receive_time, Some(earlier)); + assert!(!m.synced); + } + + #[test] + fn stash_early_arrival_precedence() { + let h = Height::new(5); + + // Vacant -> insert. + let mut map = HashMap::new(); + stash_early_arrival(&mut map, h, EarlyArrival::Synced(SystemTime::now())); + assert!(matches!(map.get(&h), Some(EarlyArrival::Synced(_)))); + + // Pending-parts supersedes a stashed synced. + stash_early_arrival( + &mut map, + h, + EarlyArrival::PendingPartsTime(SystemTime::now()), + ); + assert!(matches!( + map.get(&h), + Some(EarlyArrival::PendingPartsTime(_)) + )); + + // Synced does NOT displace a stashed pending-parts. + stash_early_arrival(&mut map, h, EarlyArrival::Synced(SystemTime::now())); + assert!(matches!( + map.get(&h), + Some(EarlyArrival::PendingPartsTime(_)) + )); + + // Same-kind keeps the first (earliest) entry. + let first = SystemTime::now() - Duration::from_millis(50); + let mut map2 = HashMap::new(); + stash_early_arrival(&mut map2, h, EarlyArrival::PendingPartsTime(first)); + stash_early_arrival( + &mut map2, + h, + EarlyArrival::PendingPartsTime(SystemTime::now()), + ); + match map2.get(&h) { + Some(EarlyArrival::PendingPartsTime(t)) => assert_eq!(*t, first), + other => panic!("expected first PendingPartsTime, got {other:?}"), + } + } + + #[test] + fn record_synced_direct_when_monitor_matches_and_unset() { + let mut monitor = Some(ProposalMonitor::new( + Height::new(4), + test_address(), + SystemTime::now(), + )); + let mut map = HashMap::new(); + let now = SystemTime::now(); + + record_synced(&mut monitor, &mut map, Height::new(4), now); + + let m = monitor.unwrap(); + assert_eq!(m.proposal_receive_time, Some(now)); + assert!(m.synced); + assert!(map.is_empty(), "should not stash when monitor matches"); + } + + #[test] + fn record_synced_does_not_override_existing_receive_time() { + let mut monitor = Some(ProposalMonitor::new( + Height::new(4), + test_address(), + SystemTime::now(), + )); + let earlier = SystemTime::now() - Duration::from_millis(10); + monitor.as_mut().unwrap().proposal_receive_time = Some(earlier); + let mut map = HashMap::new(); + + record_synced(&mut monitor, &mut map, Height::new(4), SystemTime::now()); + + // Normal proposal already recorded -> synced does not overwrite. + assert_eq!(monitor.unwrap().proposal_receive_time, Some(earlier)); + } + + #[test] + fn record_synced_stashes_when_monitor_absent_or_wrong_height() { + // No monitor -> stash. + let mut monitor = None; + let mut map = HashMap::new(); + record_synced(&mut monitor, &mut map, Height::new(9), SystemTime::now()); + assert!(matches!( + map.get(&Height::new(9)), + Some(EarlyArrival::Synced(_)) + )); + + // Monitor for a different height -> stash. + let mut monitor = Some(ProposalMonitor::new( + Height::new(8), + test_address(), + SystemTime::now(), + )); + let mut map = HashMap::new(); + record_synced(&mut monitor, &mut map, Height::new(9), SystemTime::now()); + assert!(matches!( + map.get(&Height::new(9)), + Some(EarlyArrival::Synced(_)) + )); + } + + #[test] + fn attach_value_id_to_monitor_sets_when_matching() { + let mut monitor = Some(ProposalMonitor::new( + Height::new(2), + test_address(), + SystemTime::now(), + )); + let v = test_value_id(0x55); + attach_value_id_to_monitor(&mut monitor, Height::new(2), v); + assert_eq!(monitor.unwrap().value_id, Some(v)); + } + + #[test] + fn attach_value_id_to_monitor_noop_when_absent_or_mismatched() { + // Absent monitor: no panic, nothing to assert beyond "still None". + let mut monitor: Option = None; + attach_value_id_to_monitor(&mut monitor, Height::new(2), test_value_id(0x55)); + assert!(monitor.is_none()); + + // Wrong height: value id not set. + let mut monitor = Some(ProposalMonitor::new( + Height::new(2), + test_address(), + SystemTime::now(), + )); + attach_value_id_to_monitor(&mut monitor, Height::new(3), test_value_id(0x55)); + assert!(monitor.unwrap().value_id.is_none()); + } + + #[test] + fn attach_valid_proposal_value_ids_round0_attaches_valid_skips_invalid() { + let height = Height::new(6); + let proposer = test_address(); + + // Monitor initialized from an early pending-parts arrival (value_id unset). + let mut monitor = Some(build_monitor( + height, + proposer, + SystemTime::now(), + Some(EarlyArrival::PendingPartsTime( + SystemTime::now() - Duration::from_millis(50), + )), + )); + + // An invalid proposal must not be recorded. + let invalid = test_proposed_value(height, Round::new(0), proposer, Validity::Invalid, 0x11); + attach_valid_proposal_value_ids(&mut monitor, height, Round::new(0), &[invalid]); + assert!(monitor.as_ref().unwrap().value_id.is_none()); + + // A valid proposal is recorded. + let valid = test_proposed_value(height, Round::new(0), proposer, Validity::Valid, 0x22); + attach_valid_proposal_value_ids(&mut monitor, height, Round::new(0), &[valid]); + assert_eq!(monitor.unwrap().value_id, Some(test_value_id(0x22))); + } + + #[test] + fn attach_valid_proposal_value_ids_noop_off_round0() { + let height = Height::new(6); + let proposer = test_address(); + let mut monitor = Some(ProposalMonitor::new(height, proposer, SystemTime::now())); + + let valid = test_proposed_value(height, Round::new(1), proposer, Validity::Valid, 0x22); + attach_valid_proposal_value_ids(&mut monitor, height, Round::new(1), &[valid]); + + assert!(monitor.unwrap().value_id.is_none()); + } +} diff --git a/crates/malachite-app/src/streaming.rs b/crates/malachite-app/src/streaming.rs index 56f1bf55..5ec2122a 100644 --- a/crates/malachite-app/src/streaming.rs +++ b/crates/malachite-app/src/streaming.rs @@ -50,8 +50,18 @@ pub(crate) const CHUNK_SIZE: usize = 128 * 1024; /// Maximum age for a stream before it's evicted const MAX_STREAM_AGE: Duration = Duration::from_secs(60); -/// Maximum number of evicted streams tracked in the LRU cache. -const MAX_EVICTED_STREAMS: usize = 10_000; +/// Maximum number of closed stream keys tracked in the LRU cache. +/// +/// A key is closed either because its stream was evicted (stale height, +/// oversized chunk, message-limit breach, age/global eviction) or because it +/// completed and the assembled proposal reached a terminal disposition (stored, +/// ignored as past-height, or rejected). Closed keys are dropped at the front +/// of [`PartStreamsMap::insert`] so resurfaced duplicates cannot reopen a slot. +/// +/// At sub-second finality this bound covers a wide recent window; anything +/// resurfacing older than that is almost certainly from a past height and is +/// caught by the staleness check (if it carries an `Init`) or the age timer. +const MAX_CLOSED_STREAMS: usize = 10_000; /// Stream IDs are exactly 16 bytes: u64 height + u32 round + u32 nonce. pub(crate) const STREAM_ID_LEN: usize = size_of::() + size_of::() + size_of::(); @@ -68,6 +78,12 @@ fn max_total_streams(num_validators: usize) -> usize { .max(MAX_STREAMS_PER_PEER) } +/// Returns `true` when `sequence` is a valid Fin sequence, i.e. it can be +/// safely cast to `usize` and `sequence + 1 <= MAX_MESSAGES_PER_STREAM`. +fn is_valid_fin_sequence(sequence: Sequence) -> bool { + usize::try_from(sequence).is_ok_and(|s| s < MAX_MESSAGES_PER_STREAM) +} + /// Outcome of [`PartStreamsMap::insert`]. #[derive(Debug)] pub enum InsertResult { @@ -225,6 +241,7 @@ enum StreamInsertResult { Duplicate, Incomplete(Option), ExceededMaxMessages, + InvalidFinSequence(u64), ExceededMaxChunkSize(usize), Complete(Vec), } @@ -251,6 +268,10 @@ impl StreamState { } } + if msg.is_fin() && !is_valid_fin_sequence(msg.sequence) { + return StreamInsertResult::InvalidFinSequence(msg.sequence); + } + if !self.seen_sequences.insert(msg.sequence) { // We have already seen a message with this sequence number, ignore it. return StreamInsertResult::Duplicate; @@ -280,8 +301,8 @@ impl StreamState { // If we have received the fin message, we can determine when we will be done. // We are done if we have already received all messages from 0 to fin.sequence, // included. That is to say, if we have received `fin.sequence + 1` messages. - // Sequence is a u64 protocol field; on 64-bit targets usize == u64. - // The +1 cannot overflow because MAX_MESSAGES_PER_STREAM << u64::MAX. + // `is_valid_fin_sequence` rejected sequences >= MAX_MESSAGES_PER_STREAM, + // so the cast and +1 cannot truncate or overflow. #[allow(clippy::cast_possible_truncation, clippy::arithmetic_side_effects)] { self.expected_messages = msg.sequence as usize + 1; @@ -319,6 +340,8 @@ impl StreamState { /// - Evict streams older than [`MAX_STREAM_AGE`] /// - Immediately evict streams that exceed message or size limits /// - Immediately evict streams from previous heights +/// - Drop messages for closed keys (evicted streams, or completed streams whose +/// proposal reached a terminal disposition) so duplicates cannot reopen a slot /// /// Worst-case memory at full saturation: /// = max_total_streams * MAX_MESSAGES_PER_STREAM * CHUNK_SIZE @@ -330,7 +353,10 @@ pub struct PartStreamsMap { /// [`MAX_STREAMS_PER_PEER`] during the pre-validator-set startup window. max_total_streams: usize, streams: BTreeMap<(PeerId, StreamId), StreamState>, - evicted: LruMap<(PeerId, StreamId), ()>, + /// Keys that are terminal — evicted streams, or completed streams whose + /// proposal was stored, ignored, or rejected. Bounded by + /// [`MAX_CLOSED_STREAMS`]; the oldest entry is dropped when full. + closed_keys: LruMap<(PeerId, StreamId), ()>, last_eviction: Instant, } @@ -343,9 +369,9 @@ impl PartStreamsMap { Self { streams: BTreeMap::new(), last_eviction: Instant::now(), - // MAX_EVICTED_STREAMS (10_000) fits in u32 + // MAX_CLOSED_STREAMS (10_000) fits in u32 #[allow(clippy::cast_possible_truncation)] - evicted: LruMap::new(ByLength::new(MAX_EVICTED_STREAMS as u32)), + closed_keys: LruMap::new(ByLength::new(MAX_CLOSED_STREAMS as u32)), current_height, max_total_streams: max_total_streams(num_validators), } @@ -354,10 +380,9 @@ impl PartStreamsMap { /// Update the current height, purging any tracked streams that are now /// stale so they stop consuming per-peer budget before the age sweep would /// reach them. - /// /// Height comes from the stream_id (see [`new_stream_id`]); lone non-Init /// streams carry no payload height. Purged streams are not added to - /// `evicted`: the up-front height check in [`insert`](Self::insert) is + /// `closed_keys`: the up-front height check in [`insert`](Self::insert) is /// idempotent and prevents re-creation. pub fn set_current_height(&mut self, height: Height) { self.current_height = height; @@ -442,7 +467,7 @@ impl PartStreamsMap { // First, evict any streams that have exceeded MAX_STREAM_AGE self.evict_old_streams(); - if self.evicted.peek(&key).is_some() { + if self.closed_keys.peek(&key).is_some() { return InsertResult::Pending; } @@ -503,6 +528,19 @@ impl PartStreamsMap { return InsertResult::Pending; } + StreamInsertResult::InvalidFinSequence(sequence) => { + warn!( + %peer_id, + %stream_id, + sequence, + max = MAX_MESSAGES_PER_STREAM, + "Fin sequence out of range, evicting stream" + ); + + self.evict(&key); + return InsertResult::Pending; + } + StreamInsertResult::ExceededMaxChunkSize(actual) => { warn!( %peer_id, @@ -546,12 +584,36 @@ impl PartStreamsMap { } } - /// Evict a stream from the map and mark it as evicted. - /// The evicted LRU map is bounded by [`MAX_EVICTED_STREAMS`]; the oldest + /// Evict a stream from the map and record its key as closed. + /// The closed-keys LRU map is bounded by [`MAX_CLOSED_STREAMS`]; the oldest /// entry is automatically dropped when capacity is exceeded. fn evict(&mut self, key: &(PeerId, StreamId)) { self.streams.remove(key); - self.evicted.insert((key.0, key.1.clone()), ()); + self.closed_keys.insert((key.0, key.1.clone()), ()); + } + + /// Record a key as closed after its stream completed and the assembled + /// proposal reached a terminal disposition (stored, ignored as past-height, + /// or rejected). + /// + /// Subsequent messages for this key — including resurfaced duplicates of the + /// already-completed stream — are dropped at the front of [`Self::insert`] + /// instead of reopening a slot. Callers must NOT close a key whose proposal + /// was only transiently declined (too far in the future, or the pending + /// table was full), because a later copy could still become storable. + /// + /// Shares the bounded [`MAX_CLOSED_STREAMS`] LRU with evicted streams. + pub fn mark_closed(&mut self, peer_id: PeerId, stream_id: StreamId) { + self.closed_keys.insert((peer_id, stream_id), ()); + } + + /// Test-only: whether a key has been recorded as closed (evicted or + /// terminally completed). Lets sibling-module tests observe `mark_closed`. + #[cfg(test)] + pub(crate) fn is_closed(&self, peer_id: PeerId, stream_id: &StreamId) -> bool { + self.closed_keys + .peek(&(peer_id, stream_id.clone())) + .is_some() } /// Evict streams that have exceeded MAX_STREAM_AGE @@ -1101,6 +1163,98 @@ mod tests { assert_eq!(map.streams.len(), 0, "Stream has been evicted"); } + #[test] + fn test_fin_sequence_max_allowed_completes() { + let peer = PeerId::random(); + let stream = make_stream_id(101); + let mut map = PartStreamsMap::new(Height::new(1), NUM_VALIDATORS); + + assert!(map + .must_insert(peer, make_message(&stream, 0, make_init_part())) + .is_none()); + + for i in 1..(MAX_MESSAGES_PER_STREAM - 2) { + assert!(map + .must_insert( + peer, + make_message(&stream, i as u64, make_data_part(i as u8)) + ) + .is_none()); + } + + let proposal_fin_sequence = (MAX_MESSAGES_PER_STREAM - 2) as u64; + assert!(map + .must_insert( + peer, + make_message(&stream, proposal_fin_sequence, make_fin_part()), + ) + .is_none()); + + let stream_fin_sequence = (MAX_MESSAGES_PER_STREAM - 1) as u64; + let result = map.must_insert(peer, make_fin_message(&stream, stream_fin_sequence)); + + assert!( + result.is_some(), + "Fin at the maximum allowed sequence should complete" + ); + assert!(map.streams.is_empty(), "Completed stream should be removed"); + } + + #[test] + fn test_fin_sequence_at_max_messages_per_stream_evicts() { + let peer = PeerId::random(); + let stream = make_stream_id(101); + let mut map = PartStreamsMap::new(Height::new(1), NUM_VALIDATORS); + + assert!(map + .must_insert(peer, make_message(&stream, 0, make_init_part())) + .is_none()); + + let result = map.must_insert( + peer, + make_fin_message(&stream, MAX_MESSAGES_PER_STREAM as u64), + ); + + assert!(result.is_none(), "Out-of-range Fin should be pending"); + assert!( + map.streams.is_empty(), + "Stream should be evicted after out-of-range Fin" + ); + assert!( + map.closed_keys.peek(&(peer, stream)).is_some(), + "Stream should be marked as evicted" + ); + } + + #[test] + fn test_fin_sequence_u64_max_evicts_without_panicking() { + let peer = PeerId::random(); + let stream = make_stream_id(101); + let mut map = PartStreamsMap::new(Height::new(1), NUM_VALIDATORS); + + assert!(map + .must_insert(peer, make_message(&stream, 0, make_init_part())) + .is_none()); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + map.insert(peer, make_fin_message(&stream, u64::MAX)) + })); + + let result = result.expect("Out-of-range Fin should not panic"); + assert!( + matches!(result, InsertResult::Pending), + "Out-of-range Fin should be rejected as pending" + ); + assert!( + map.streams.is_empty(), + "Stream should be evicted after out-of-range Fin" + ); + assert!( + map.closed_keys.peek(&(peer, stream)).is_some(), + "Stream should be marked as evicted" + ); + } + #[test] fn test_per_peer_limit_independent_across_peers() { let peer_1 = PeerId::random(); @@ -1566,7 +1720,7 @@ mod tests { )); assert_eq!(map.streams.len(), 0, "Mismatched stream should be evicted"); assert!( - map.evicted.peek(&(peer_1, stream_1)).is_some(), + map.closed_keys.peek(&(peer_1, stream_1)).is_some(), "Mismatched stream should be marked as evicted" ); } @@ -1694,7 +1848,7 @@ mod tests { )); assert_eq!(map.streams.len(), 0, "completed stream must be removed"); assert!( - map.evicted.peek(&(peer, stream)).is_some(), + map.closed_keys.peek(&(peer, stream)).is_some(), "Mismatched stream should be marked as evicted" ); } @@ -1712,7 +1866,7 @@ mod tests { } assert!( - !map.evicted.is_empty(), + !map.closed_keys.is_empty(), "Evicted set should contain entries" ); @@ -1722,7 +1876,7 @@ mod tests { // Evicted entries should be retained — the LRU is self-bounding assert!( - !map.evicted.is_empty(), + !map.closed_keys.is_empty(), "Evicted set should be retained across eviction cycles" ); } @@ -1750,7 +1904,7 @@ mod tests { "Stream should be evicted after oversized chunk" ); assert!( - map.evicted.peek(&(peer, stream)).is_some(), + map.closed_keys.peek(&(peer, stream)).is_some(), "Stream should be marked as evicted" ); } @@ -1862,7 +2016,7 @@ mod tests { // Send many Init messages whose payload height disagrees with the // current-height stream ID. Each mismatch is rejected, evicted, and // recorded in the evicted set. - let count = MAX_EVICTED_STREAMS + 500; + let count = MAX_CLOSED_STREAMS + 500; for i in 0..count { let mut init = make_init_part(); if let ProposalPart::Init(ref mut part) = init { @@ -1877,17 +2031,17 @@ mod tests { )); assert!( - map.evicted.len() <= MAX_EVICTED_STREAMS, - "Evicted set should never exceed MAX_EVICTED_STREAMS, got {}", - map.evicted.len() + map.closed_keys.len() <= MAX_CLOSED_STREAMS, + "Evicted set should never exceed MAX_CLOSED_STREAMS, got {}", + map.closed_keys.len() ); } // After the loop, evicted should have been cleared at least once assert!( - map.evicted.len() <= MAX_EVICTED_STREAMS, + map.closed_keys.len() <= MAX_CLOSED_STREAMS, "Evicted set should be bounded, got {}", - map.evicted.len() + map.closed_keys.len() ); // Map should still function correctly after clearing. @@ -1993,6 +2147,83 @@ mod tests { ); } + #[test] + fn test_resurfaced_part_reopens_completed_stream_without_mark_closed() { + // Documents the gap that `mark_closed` closes: after a stream completes + // and is removed, a resurfaced non-`Init` straggler creates a fresh + // `height = None` stream that escapes the staleness check and lingers, + // holding a slot until the age timer reaps it. + let peer = PeerId::random(); + let stream = make_stream_id(1); + let mut map = PartStreamsMap::new(Height::new(1), NUM_VALIDATORS); + + map.must_insert(peer, make_message(&stream, 0, make_init_part())); + map.must_insert(peer, make_message(&stream, 1, make_fin_part())); + assert!(map + .must_insert(peer, make_fin_message(&stream, 2)) + .is_some()); + assert_eq!(map.streams.len(), 0, "completed stream is removed"); + + // A resurfaced straggler (the original fin part) carries no height. + let resurfaced = make_message(&stream, 1, make_fin_part()); + assert!(map.must_insert(peer, resurfaced).is_none()); + assert_eq!( + map.streams.len(), + 1, + "without mark_closed, a resurfaced straggler reopens a lingering stream" + ); + } + + #[test] + fn test_mark_closed_drops_resurfaced_part() { + // With the completed key marked closed, the same resurfaced straggler is + // dropped at the front of `insert` and never reopens a slot. + let peer = PeerId::random(); + let stream = make_stream_id(1); + let mut map = PartStreamsMap::new(Height::new(1), NUM_VALIDATORS); + + map.must_insert(peer, make_message(&stream, 0, make_init_part())); + map.must_insert(peer, make_message(&stream, 1, make_fin_part())); + assert!(map + .must_insert(peer, make_fin_message(&stream, 2)) + .is_some()); + assert_eq!(map.streams.len(), 0, "completed stream is removed"); + + map.mark_closed(peer, stream.clone()); + + let resurfaced = make_message(&stream, 1, make_fin_part()); + assert!(map.must_insert(peer, resurfaced).is_none()); + assert_eq!( + map.streams.len(), + 0, + "a closed key must not reopen a stream" + ); + assert!( + map.closed_keys.peek(&(peer, stream)).is_some(), + "key should be recorded as closed" + ); + } + + #[test] + fn test_mark_closed_does_not_count_toward_per_peer_limit() { + // Closed keys live in the LRU, not in `streams`, so they never consume a + // per-peer slot. + let peer = PeerId::random(); + let mut map = PartStreamsMap::new(Height::new(1), NUM_VALIDATORS); + + for i in 0..MAX_STREAMS_PER_PEER as u8 { + map.mark_closed(peer, make_stream_id(i)); + } + assert_eq!(map.peer_streams_count(peer), 0); + + // A brand-new stream is still accepted despite the closed keys. + let fresh = make_stream_id(200); + assert!(map + .must_insert(peer, make_message(&fresh, 0, make_init_part())) + .is_none()); + assert_eq!(map.streams.len(), 1); + } + // --- Property-Based Tests --- proptest! { diff --git a/crates/malachite-app/src/utils/coord_upgrade.rs b/crates/malachite-app/src/utils/coord_upgrade.rs index f53a4ddd..55d4cc6c 100644 --- a/crates/malachite-app/src/utils/coord_upgrade.rs +++ b/crates/malachite-app/src/utils/coord_upgrade.rs @@ -23,8 +23,18 @@ use tracing::{info, warn}; use crate::store::Store; #[derive(Debug, thiserror::Error)] -#[error("Halt and wait for external termination signal")] -pub struct HaltAndWait; +#[error("Halt and wait for external termination signal: {reason}")] +pub struct HaltAndWait { + pub reason: String, +} + +impl HaltAndWait { + pub fn new(reason: impl Into) -> Self { + Self { + reason: reason.into(), + } + } +} /// Check if the next height matches the configured halt height. pub async fn check_halt_height( @@ -45,7 +55,9 @@ pub async fn check_halt_height( info!("Sleeping {SLEEP_BEFORE_HALT:?} before halting..."); sleep(Duration::from_secs(10)).await; - return Err(HaltAndWait.into()); + return Err( + HaltAndWait::new(format!("reached configured halt height {height}")).into(), + ); } } diff --git a/crates/malachite-app/tests/common/mod.rs b/crates/malachite-app/tests/common/mod.rs index 29c500e7..2fee42d6 100644 --- a/crates/malachite-app/tests/common/mod.rs +++ b/crates/malachite-app/tests/common/mod.rs @@ -54,8 +54,9 @@ impl TestServer { .expect("Failed to bind to random port"); let addr = listener.local_addr().expect("Failed to get local address"); - // Build the actual production router - let router = arc_node_consensus::rpc::build_router(consensus_tx, app_tx, network_tx); + // Build the actual production router, with admin routes enabled so the + // integration suite can exercise the full endpoint surface. + let router = arc_node_consensus::rpc::build_router(consensus_tx, app_tx, network_tx, true); // Spawn the server let server_handle = tokio::spawn(async move { diff --git a/crates/malachite-app/tests/rpc_integration.rs b/crates/malachite-app/tests/rpc_integration.rs index 498ad5ef..4c3082a6 100644 --- a/crates/malachite-app/tests/rpc_integration.rs +++ b/crates/malachite-app/tests/rpc_integration.rs @@ -22,14 +22,36 @@ use std::time::Duration; -use arc_consensus_types::{signing::PrivateKey, Address, Height, Round, ValidatorSet}; -use arc_node_consensus::request::{AppRequest, Status}; +use arc_consensus_types::{ + signing::PrivateKey, Address, BlockHash, CommitCertificateType, Height, Round, ValidatorSet, + ValueId, +}; +use arc_node_consensus::request::{AppRequest, CommitCertificateInfo, HeightRangeRequest, Status}; +use arc_node_consensus::store::{RangeFailureReason, RangeQueryResult}; use arc_node_consensus::utils::sync_state::SyncState; use malachitebft_app_channel::{ConsensusRequest, NetworkRequest}; +use malachitebft_core_types::CommitCertificate; mod common; use common::TestServer; +/// Build a stand-in `CommitCertificateInfo` at `height` for mock range replies. +/// +/// Only `height` varies; the certificate body, type, and proposer are fixed, so +/// a range reply of these can assert response ordering by height alone. +fn a_commit_cert_info_at(height: u64) -> CommitCertificateInfo { + CommitCertificateInfo { + certificate: CommitCertificate::new( + Height::new(height), + Round::new(0), + ValueId::new(BlockHash::new([0xAA; 32])), + vec![], + ), + certificate_type: CommitCertificateType::Minimal, + proposer: Address::repeat_byte(0x55), + } +} + /// Test that the root endpoint returns API documentation #[tokio::test] async fn test_root_endpoint_returns_docs() { @@ -160,7 +182,7 @@ async fn test_commit_endpoint_with_height() { // Respond to certificate request server.expect_app_request(|req| match req { - AppRequest::GetCertificate(height, reply) => { + AppRequest::GetCertificate { height, reply, .. } => { assert_eq!(height, Some(Height::new(42))); reply.send(None).ok(); } @@ -177,6 +199,155 @@ async fn test_commit_endpoint_with_height() { assert_eq!(response.status(), 404); // Not found since we returned None } +/// count > 1 returns an ordered JSON array over real HTTP. +#[tokio::test] +async fn test_commit_range_returns_array() { + let server = TestServer::start().await; + + server.expect_app_request(|req| match req { + AppRequest::GetCertificateRange(range, reply) => { + assert_eq!( + range, + HeightRangeRequest { + from: Height::new(7), + count: 2 + } + ); + reply + .send(Some(RangeQueryResult::Complete(vec![ + a_commit_cert_info_at(7), + a_commit_cert_info_at(8), + ]))) + .ok(); + } + _ => panic!("Unexpected request type"), + }); + + let client = reqwest::Client::new(); + let response = client + .get(format!("{}/commit?height=7&count=2", server.url())) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), 200); + let body: serde_json::Value = response.json().await.expect("Failed to parse JSON"); + let heights: Vec = body + .as_array() + .expect("array body") + .iter() + .map(|c| c.get("height").unwrap().as_u64().unwrap()) + .collect(); + assert_eq!(heights, vec![7, 8]); +} + +/// An over-limit range is rejected pre-send with the structured 400 body. +/// No app request is registered, proving the rejection happens before the +/// request reaches the consensus task. +#[tokio::test] +async fn test_commit_range_over_limit_structured_error() { + let server = TestServer::start().await; + + let client = reqwest::Client::new(); + let response = client + .get(format!("{}/commit?height=10&count=1001", server.url())) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("Failed to parse JSON"); + assert_eq!( + body, + serde_json::json!({ + "error": "partial range unavailable", + "requested": {"from": 10, "to": 1010}, + "reason": "over_limit" + }) + ); + assert!(body.get("failed_heights").is_none()); +} + +/// An unavailable sub-range returns the structured 400 with failed heights. +#[tokio::test] +async fn test_commit_range_above_head_structured_error() { + let server = TestServer::start().await; + + server.expect_app_request(|req| match req { + AppRequest::GetCertificateRange(_, reply) => { + reply + .send(Some(RangeQueryResult::Unavailable { + reason: RangeFailureReason::AboveCurrentHead, + failed_heights: vec![Height::new(11), Height::new(12)], + })) + .ok(); + } + _ => panic!("Unexpected request type"), + }); + + let client = reqwest::Client::new(); + let response = client + .get(format!("{}/commit?height=8&count=5", server.url())) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), 400); + let body: serde_json::Value = response.json().await.expect("Failed to parse JSON"); + assert_eq!( + body, + serde_json::json!({ + "error": "partial range unavailable", + "requested": {"from": 8, "to": 12}, + "failed_heights": [11, 12], + "reason": "above_current_head" + }) + ); +} + +/// A client that sends `Accept-Encoding: gzip` gets a gzip-compressed body +/// over real HTTP. +#[tokio::test] +async fn test_response_gzip_round_trip() { + let server = TestServer::start().await; + + server.expect_app_request(|req| match req { + AppRequest::GetCertificateRange(_, reply) => { + reply + .send(Some(RangeQueryResult::Complete(vec![ + a_commit_cert_info_at(7), + a_commit_cert_info_at(8), + ]))) + .ok(); + } + _ => panic!("Unexpected request type"), + }); + + let client = reqwest::Client::new(); + let response = client + .get(format!("{}/commit?height=7&count=2", server.url())) + .header("accept-encoding", "gzip") + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-encoding").unwrap(), "gzip"); + + let raw = response.bytes().await.expect("Failed to read body"); + let mut decoder = flate2::read::GzDecoder::new(&raw[..]); + let mut json = String::new(); + std::io::Read::read_to_string(&mut decoder, &mut json).expect("Failed to gunzip"); + let body: serde_json::Value = serde_json::from_str(&json).expect("Failed to parse JSON"); + let heights: Vec = body + .as_array() + .expect("array body") + .iter() + .map(|c| c.get("height").unwrap().as_u64().unwrap()) + .collect(); + assert_eq!(heights, vec![7, 8]); +} + /// Test the /commit endpoint without height parameter #[tokio::test] async fn test_commit_endpoint_without_height() { @@ -184,7 +355,7 @@ async fn test_commit_endpoint_without_height() { // Respond to certificate request server.expect_app_request(|req| match req { - AppRequest::GetCertificate(height, reply) => { + AppRequest::GetCertificate { height, reply, .. } => { assert_eq!(height, None); reply.send(None).ok(); } diff --git a/crates/malachite-cli/Cargo.toml b/crates/malachite-cli/Cargo.toml index aeb5f271..800a5a6d 100644 --- a/crates/malachite-cli/Cargo.toml +++ b/crates/malachite-cli/Cargo.toml @@ -6,6 +6,9 @@ repository.workspace = true license.workspace = true publish.workspace = true +[features] +byzantine = ["arc-consensus-types/byzantine"] + [dependencies] arc-consensus-types.workspace = true @@ -32,6 +35,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } +tower-http = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } @@ -41,6 +45,8 @@ url = { workspace = true } lz4.workspace = true tar.workspace = true tempfile = { workspace = true } +tower = { workspace = true } +tracing-test = "0.2" wiremock.workspace = true [lints] diff --git a/crates/malachite-cli/src/args.rs b/crates/malachite-cli/src/args.rs index 09eaa5f7..fc2dec58 100644 --- a/crates/malachite-cli/src/args.rs +++ b/crates/malachite-cli/src/args.rs @@ -16,9 +16,6 @@ //! Command-line interface arguments for a basic implementation. //! -//! Read configuration from the configuration files found in the directory -//! provided with the `--home` global parameter. -//! //! The command-line parameters are stored in the `Args` structure. //! `clap` parses the command-line parameters into this structure. @@ -37,7 +34,6 @@ use crate::cmd::start::StartCmd; use crate::error::Error; const APP_FOLDER: &str = ".arc/consensus"; -const CONFIG_FILE: &str = "config.toml"; const GENESIS_FILE: &str = "genesis.json"; const PRIV_VALIDATOR_KEY_FILE: &str = "priv_validator_key.json"; @@ -119,12 +115,6 @@ impl Args { Ok(self.get_home_dir()?.join("config")) } - /// get_config_file_path returns the configuration file path based on the command-line arguments - /// and the configuration folder. - pub fn get_config_file_path(&self) -> Result { - Ok(self.get_config_dir()?.join(CONFIG_FILE)) - } - /// get_genesis_file_path returns the genesis file path based on the command-line arguments and /// the configuration folder. pub fn get_genesis_file_path(&self) -> Result { diff --git a/crates/malachite-cli/src/cmd/download.rs b/crates/malachite-cli/src/cmd/download.rs index 28385f65..36abcf62 100644 --- a/crates/malachite-cli/src/cmd/download.rs +++ b/crates/malachite-cli/src/cmd/download.rs @@ -21,15 +21,12 @@ use std::path::Path; -use arc_snapshots::download::{ - consensus_snapshot_exists, fetch_latest_snapshot_urls, should_download, stream_and_extract, - write_snapshot_version, Chain, -}; +use arc_snapshots::download::{fetch_latest_consensus_url, stream_restore_consensus, Chain}; use clap::Args; use eyre::Result; use tracing::info; -#[derive(Args, Clone, Debug, Default)] +#[derive(Args, Clone, Debug)] pub struct DownloadCmd { /// URL of the CL snapshot to download. /// @@ -38,10 +35,8 @@ pub struct DownloadCmd { pub url: Option, /// Network to download a snapshot for. - /// - /// [possible values: arc-testnet, arc-devnet] #[arg(long, default_value = "arc-testnet")] - pub chain: String, + pub chain: Chain, /// Force re-download even if snapshot data already exists. #[arg(long = "force")] @@ -50,70 +45,88 @@ pub struct DownloadCmd { impl DownloadCmd { pub async fn run(&self, home_dir: &Path) -> Result<()> { - let chain = parse_chain(&self.chain)?; - let url = match &self.url { Some(u) => u.clone(), None => { info!(chain = %self.chain, "Fetching latest CL snapshot URL"); - let (_el_url, cl_url) = fetch_latest_snapshot_urls(chain).await?; - cl_url + fetch_latest_consensus_url(self.chain).await? } }; - if !should_download( - "Consensus layer", - home_dir, - &url, - consensus_snapshot_exists(home_dir), - self.force_redownload, - ) { - return Ok(()); - } - - let tmp_dir = home_dir.join(".snapshot-tmp"); - info!( url = %url, home_dir = %home_dir.display(), "Starting CL snapshot download" ); - stream_and_extract(url.clone(), home_dir.to_path_buf(), tmp_dir).await?; - write_snapshot_version(home_dir, &url)?; + // One implementation, shared with arc-snapshots: it decides whether the + // restore is needed, stages the archive, and records what was restored. + // + // Staging inside the home is safe here and nowhere else: the consensus + // restore never removes its target, and the node reads `store.db`, + // `config/` and `wal/` — never `.snapshot-tmp`. Keeping it inside also + // keeps the archive on the volume the operator gave the home, which a + // parent directory is not guaranteed to be. + stream_restore_consensus( + url, + home_dir.to_path_buf(), + home_dir.join(".snapshot-tmp"), + self.force_redownload, + ) + .await?; - info!("CL snapshot downloaded and extracted successfully"); + info!("CL snapshot restore complete"); Ok(()) } } -fn parse_chain(name: &str) -> Result { - match name { - "arc-testnet" => Ok(Chain::Testnet), - "arc-devnet" => Ok(Chain::Devnet), - other => Err(eyre::eyre!( - "Unknown chain '{}'. Valid values: arc-testnet, arc-devnet", - other - )), - } -} - #[cfg(test)] mod tests { + use clap::Parser; + use super::*; + /// Wraps the command so `--chain` can be parsed the way the real CLI parses + /// it, rather than through a second hand-written parser. + #[derive(Debug, Parser)] + struct TestCli { + #[command(flatten)] + cmd: DownloadCmd, + } + + fn parse_chain_arg(value: &str) -> Result { + TestCli::try_parse_from(["arc-node-consensus", "--chain", value]).map(|cli| cli.cmd.chain) + } + #[test] - fn parse_chain_known_values() { + fn chain_accepts_every_supported_network() { + // arc-mainnet in particular: the CL used to reject it while the + // execution side and the docs both advertised it. assert!(matches!( - parse_chain("arc-testnet").unwrap(), + parse_chain_arg("arc-testnet").unwrap(), Chain::Testnet )); - assert!(matches!(parse_chain("arc-devnet").unwrap(), Chain::Devnet)); + assert!(matches!( + parse_chain_arg("arc-devnet").unwrap(), + Chain::Devnet + )); + assert!(matches!( + parse_chain_arg("arc-mainnet").unwrap(), + Chain::Mainnet + )); + } + + #[test] + fn chain_rejects_unknown_and_unprefixed_values() { + assert!(parse_chain_arg("unknown").is_err()); + // The bare network name is what the snapshot API uses, not the CLI. + assert!(parse_chain_arg("testnet").is_err()); } #[test] - fn parse_chain_unknown_is_error() { - assert!(parse_chain("unknown").is_err()); + fn chain_defaults_to_testnet() { + let cli = TestCli::try_parse_from(["arc-node-consensus"]).unwrap(); + assert!(matches!(cli.cmd.chain, Chain::Testnet)); } #[tokio::test] @@ -146,21 +159,22 @@ mod tests { .await; let dir = tempfile::tempdir()?; + let home = dir.path().join("consensus"); let cmd = DownloadCmd { url: Some(format!("{}/cl.tar.lz4", server.uri())), - chain: "arc-devnet".into(), + chain: Chain::Devnet, force_redownload: false, }; let url = format!("{}/cl.tar.lz4", server.uri()); - cmd.run(dir.path()).await?; + cmd.run(&home).await?; - assert!(dir.path().join("store.db").exists()); + assert!(home.join("store.db").exists()); // Version marker should be written - assert_eq!( - std::fs::read_to_string(dir.path().join(".snapshot-url"))?, - url - ); + assert_eq!(std::fs::read_to_string(home.join(".snapshot-url"))?, url); + // Staging is cleaned up, so the ~14 GB archive does not sit in the home + // beside the store it was unpacked into. + assert!(!home.join(".snapshot-tmp").exists()); Ok(()) } @@ -194,34 +208,25 @@ mod tests { .await; let dir = tempfile::tempdir()?; + let home = dir.path().join("consensus"); + std::fs::create_dir_all(&home)?; let url = format!("{}/cl.tar.lz4", server.uri()); // Pre-populate data and matching marker - std::fs::write(dir.path().join("store.db"), b"existing")?; - std::fs::write(dir.path().join(".snapshot-url"), &url)?; + std::fs::write(home.join("store.db"), b"existing")?; + std::fs::write(home.join(".snapshot-url"), &url)?; let cmd = DownloadCmd { url: Some(url), - chain: "arc-devnet".into(), + chain: Chain::Devnet, force_redownload: false, }; - cmd.run(dir.path()).await?; + cmd.run(&home).await?; // Data should be untouched - assert_eq!(std::fs::read(dir.path().join("store.db"))?, b"existing"); + assert_eq!(std::fs::read(home.join("store.db"))?, b"existing"); drop(mock); Ok(()) } - - #[tokio::test] - async fn run_errors_on_unknown_chain() { - let dir = tempfile::tempdir().unwrap(); - let cmd = DownloadCmd { - url: Some("http://example.com/cl.tar.lz4".into()), - chain: "not-a-chain".into(), - force_redownload: false, - }; - assert!(cmd.run(dir.path()).await.is_err()); - } } diff --git a/crates/malachite-cli/src/cmd/start.rs b/crates/malachite-cli/src/cmd/start.rs index 8ac42a99..dce4845a 100644 --- a/crates/malachite-cli/src/cmd/start.rs +++ b/crates/malachite-cli/src/cmd/start.rs @@ -21,7 +21,7 @@ use arc_consensus_types::rpc_sync::SyncEndpointUrl; use clap::Parser; use color_eyre::eyre; use serde::{Deserialize, Serialize}; -use tracing::info; +use tracing::{info, warn}; use url::Url; use arc_consensus_types::Address; @@ -281,6 +281,15 @@ pub struct StartCmd { #[serde(skip)] pub rpc_addr: Option, + /// Enable the admin RPC routes. + /// + /// Disabled by default. These routes mutate node state and have no + /// authentication, so they stay unreachable unless explicitly enabled. + /// Only enable on an internal, trusted RPC interface. + #[clap(long = "rpc.admin", default_value_t = false)] + #[serde(skip)] + pub rpc_admin: bool, + // ===== Runtime ===== /// Tokio runtime flavor to use. #[clap( @@ -469,6 +478,28 @@ pub struct StartCmd { #[clap(long = "follow.endpoint", value_name = "ENDPOINT", requires = "follow")] #[serde(skip)] pub follow_endpoints: Vec, + + /// Byzantine behavior configuration as a JSON blob. Testnet / Quake only. + /// + /// Deserializes into `arc_consensus_types::ByzantineConfig` and enables + /// configurable Byzantine faults: vote/proposal equivocation, message + /// dropping, inbound-proposal drops, amnesia, force-precommit-nil. + /// + /// Example: --byzantine='{"equivocate_votes":{"mode":"always"},"seed":42}' + #[cfg(feature = "byzantine")] + #[clap( + long, + value_name = "JSON", + value_parser = parse_byzantine_json, + help_heading = "Byzantine (testnet only)" + )] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub byzantine: Option, +} + +#[cfg(feature = "byzantine")] +fn parse_byzantine_json(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| format!("invalid --byzantine JSON: {e}")) } fn parse_non_zero_address(s: &str) -> Result { @@ -511,6 +542,7 @@ impl Default for StartCmd { execution_jwt: None, metrics: None, rpc_addr: None, + rpc_admin: false, runtime_flavor: RUNTIME_MULTI_THREADED.to_string(), worker_threads: None, full: false, @@ -524,6 +556,8 @@ impl Default for StartCmd { skip_db_upgrade: false, signing_remote: None, signing_tls_cert_path: None, + #[cfg(feature = "byzantine")] + byzantine: None, follow: false, follow_endpoints: Vec::new(), } @@ -626,6 +660,7 @@ impl StartCmd { push_if_some!("execution-jwt", self.execution_jwt); push_if_some!("metrics", self.metrics); push_if_some!("rpc.addr", self.rpc_addr); + push_if!("rpc.admin", self.rpc_admin); push_if!("full", self.full); push_if!("minimal", self.minimal); if let Some(ref path) = self.private_key { @@ -640,6 +675,13 @@ impl StartCmd { push_if!("follow", self.follow); push_each!("follow.endpoint", &self.follow_endpoints); + #[cfg(feature = "byzantine")] + if let Some(ref byz) = self.byzantine { + let json = + serde_json::to_string(byz).expect("ByzantineConfig serialization cannot fail"); + flags.push(format!("--byzantine={json}")); + } + flags } @@ -663,6 +705,25 @@ impl StartCmd { )); } + // The RPC/HTTP CL<->EL transport is deprecated and removed in v0.9.0. + // Warn on any RPC option; --execution-ws-endpoint only applies to RPC. + let uses_rpc_transport = self.eth_rpc_endpoint.is_some() + || self.execution_endpoint.is_some() + || self.execution_ws_endpoint.is_some() + || self.execution_jwt.is_some(); + if uses_rpc_transport { + warn!( + "DEPRECATION: the RPC/HTTP transport for CL<->EL (Engine API) \ + communication is deprecated and will be removed in v0.9.0. \ + Migrate to IPC: on the consensus layer use --eth-socket and \ + --execution-socket in place of --eth-rpc-endpoint, \ + --execution-endpoint, --execution-ws-endpoint, and \ + --execution-jwt; on the execution layer enable IPC with \ + --ipcpath, --auth-ipc, and --auth-ipc.path. See \ + docs/running-an-arc-node.md." + ); + } + // Validate persistent-peers-only configuration if self.p2p_persistent_peers_only && self.p2p_persistent_peers.is_empty() { return Err(eyre::eyre!( @@ -824,6 +885,48 @@ mod tests { ); } + #[test] + #[tracing_test::traced_test] + fn validate_warns_on_deprecated_rpc_transport() { + let mut cmd = new_start_cmd(); + cmd.eth_rpc_endpoint = Some(dummy_url()); + cmd.execution_endpoint = Some(dummy_url()); + cmd.execution_jwt = Some("/path/to/jwt.hex".to_string()); + + assert!(cmd.validate().is_ok(), "RPC options remain valid"); + assert!( + logs_contain("deprecated") && logs_contain("v0.9.0"), + "RPC transport must emit a deprecation warning naming v0.9.0" + ); + } + + #[test] + #[tracing_test::traced_test] + fn validate_warns_on_execution_ws_endpoint_alone() { + let mut cmd = new_start_cmd(); + cmd.execution_ws_endpoint = Some(dummy_url()); + + assert!(cmd.validate().is_ok(), "ws endpoint alone stays valid"); + assert!( + logs_contain("deprecated"), + "--execution-ws-endpoint must trigger the deprecation warning" + ); + } + + #[test] + #[tracing_test::traced_test] + fn validate_no_deprecation_warning_in_ipc_mode() { + let mut cmd = new_start_cmd(); + cmd.eth_socket = Some("/tmp/reth.ipc".to_string()); + cmd.execution_socket = Some("/tmp/reth-auth.ipc".to_string()); + + assert!(cmd.validate().is_ok(), "IPC options remain valid"); + assert!( + !logs_contain("deprecated"), + "IPC mode must not emit the RPC deprecation warning" + ); + } + #[test] fn validate_err_when_mixing_ipc_and_rpc() { let mut cmd = new_start_cmd(); @@ -1603,6 +1706,7 @@ mod tests { execution_jwt: None, metrics: Some("127.0.0.1:9000".parse().unwrap()), rpc_addr: Some("127.0.0.1:31000".parse().unwrap()), + rpc_admin: true, runtime_flavor: RUNTIME_SINGLE_THREADED.to_string(), worker_threads: Some(8), full: false, @@ -1620,6 +1724,10 @@ mod tests { skip_db_upgrade: true, signing_remote: Some("http://signer:10340".to_string()), signing_tls_cert_path: Some("/etc/arc/signer.pem".to_string()), + #[cfg(feature = "byzantine")] + byzantine: Some( + serde_json::from_str(r#"{"equivocate_votes":{"mode":"always"}}"#).unwrap(), + ), follow: true, follow_endpoints: vec!["http://rpc-1:8545,ws=8546".parse().unwrap()], }; diff --git a/crates/malachite-cli/src/metrics.rs b/crates/malachite-cli/src/metrics.rs index a772fe9d..60b74321 100644 --- a/crates/malachite-cli/src/metrics.rs +++ b/crates/malachite-cli/src/metrics.rs @@ -20,6 +20,7 @@ use axum::response::IntoResponse; use axum::routing::get; use axum::Router; use tokio::net::{TcpListener, ToSocketAddrs}; +use tower_http::compression::CompressionLayer; use tracing::{error, info}; use malachitebft_app::metrics::export; @@ -34,7 +35,7 @@ pub async fn serve(listen_addr: impl ToSocketAddrs) { } async fn inner(listen_addr: impl ToSocketAddrs) -> io::Result<()> { - let app = Router::new().route("/metrics", get(get_metrics)); + let app = metrics_router(); let listener = TcpListener::bind(listen_addr).await?; let local_addr = listener.local_addr()?; @@ -44,9 +45,81 @@ async fn inner(listen_addr: impl ToSocketAddrs) -> io::Result<()> { Ok(()) } +fn metrics_router() -> Router { + Router::new() + .route("/metrics", get(get_metrics)) + .layer(CompressionLayer::new()) +} + async fn get_metrics() -> impl IntoResponse { let mut buf = String::new(); export(&mut buf); ([("Content-Type", CONTENT_TYPE)], buf) } + +#[cfg(test)] +mod tests { + use axum::body::Body; + use axum::http::{header, Request, StatusCode}; + use axum::routing::get; + use axum::Router; + use tower::ServiceExt; + use tower_http::compression::CompressionLayer; + + // The global prometheus registry is empty in tests, so using metrics_router() + // directly would produce an empty body and skip compression. Build an + // equivalent router with a non-empty synthetic body to exercise the layer. + fn router_with_compression_and_non_empty_body() -> Router { + Router::new() + .route( + "/metrics", + get(|| async { + ( + [("content-type", super::CONTENT_TYPE)], + "# HELP test A test.\n# TYPE test counter\ntest_total 1\n# EOF\n", + ) + }), + ) + .layer(CompressionLayer::new()) + } + + #[tokio::test] + async fn metrics_endpoint_compresses_when_accept_encoding_gzip() { + let response = router_with_compression_and_non_empty_body() + .oneshot( + Request::builder() + .uri("/metrics") + .header(header::ACCEPT_ENCODING, "gzip") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .map(|v| v.as_bytes()), + Some(b"gzip".as_slice()), + ); + } + + #[tokio::test] + async fn metrics_endpoint_returns_plaintext_without_accept_encoding() { + let response = router_with_compression_and_non_empty_body() + .oneshot( + Request::builder() + .uri("/metrics") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + } +} diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 7ee83949..a47806d2 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -68,6 +68,7 @@ tikv-jemalloc-ctl = { version = "0.6", optional = true } tokio = { workspace = true, features = ["signal"] } tracing.workspace = true +url.workspace = true # allocator [target.'cfg(not(target_env = "msvc"))'.dependencies] diff --git a/crates/node/README.md b/crates/node/README.md index eef63693..a220e585 100644 --- a/crates/node/README.md +++ b/crates/node/README.md @@ -108,10 +108,20 @@ In addition to standard Reth flags, `arc-node-execution` provides the following | `--invalid-tx-list-enable[=]` | `true` | - | Enable the invalid transaction list feature. Opt out with `--invalid-tx-list-enable=false`. | | `--invalid-tx-list-cap ` | `100000` | - | Maximum capacity of the invalid tx list LRU cache. Only read if `--invalid-tx-list-enable` is `true`. | | `--arc.rpc.max-batch-entries ` | `100` | - | Maximum number of entries permitted in a JSON-RPC batch request. Oversized batches are rejected with JSON-RPC error `-32600` before any per-entry handler runs. Must be `>= 1`. | -| `--full` | - | - | Full-node pruning preset. Fully prunes sender recovery; keeps the last 237,600 blocks for all other segments. Also sets `--prune.block-interval=5000`. Mutually exclusive with `--minimal`. | -| `--minimal` | - | - | Minimal-storage pruning preset. Fully prunes sender recovery; keeps transaction lookup for 64 blocks, receipts for 64 blocks, account/storage history for 10,064 blocks, and block bodies for 237,600 blocks. Also sets `--prune.block-interval=5000`. Mutually exclusive with `--full`. | +| `--arc.builder.deadline ` | Reth `builder.deadline` | `ARC_BUILDER_DEADLINE_MS` | Maximum transaction-selection duration in milliseconds. Overrides the equivalent Reth builder setting. | +| `--arc.builder.wait-for-payload ` | `true` | `ARC_BUILDER_WAIT_FOR_PAYLOAD` | Wait for in-flight payload building when `engine_getPayload` arrives early instead of racing an empty block. | +| `--full` | - | - | Full-node pruning preset. Fully prunes sender recovery; keeps the last 237,600 blocks for all other segments. Also sets `--prune.block-interval=128`. Mutually exclusive with `--minimal`. | +| `--minimal` | - | - | Minimal-storage pruning preset. Fully prunes sender recovery; keeps transaction lookup for 64 blocks, receipts for 64 blocks, account/storage history for 10,064 blocks, and block bodies for 237,600 blocks. Also sets `--prune.block-interval=128`. Mutually exclusive with `--full`. | | `--arc.expose-pending-txs` | `false` | - | Expose pending-tx RPCs. By default pending-tx subscriptions, filters, and pending-block queries are blocked — set this on trusted / internal nodes where exposing pending state is intentional. | | `--public-api` | `false` | - | Convenience flag for externally-exposed RPC nodes. Forces pending-tx hiding and warns if `--http.api` / `--ws.api` expose namespaces outside `{eth, net, web3, rpc}`. Conflicts with `--arc.expose-pending-txs`. | +| `--arc.rpc.allow-unprotected-txs` | `false` | - | Accept pre-EIP-155 transactions through raw transaction submission RPC methods. | +| `--arc.tx.relays ` | - | `ARC_TX_RELAYS` | Ordered comma-separated relay URLs for transaction failover. Conflicts with `--rpc.forwarder`. | +| `--arc.tx.relays.timeout ` | `10s` | `ARC_TX_RELAYS_TIMEOUT` | Timeout for each transaction relay attempt. Accepts a duration or a number of seconds. | +| `--txpool.rebroadcast-interval ` | `60` | - | Interval in seconds between transaction rebroadcast attempts. Set to `0` to disable. | +| `--pprof.addr ` | `0.0.0.0:6061` | - | Address for the pprof HTTP server. | +| `--pprof.heap-prof` | `false` | - | Enable heap profiling when the binary is built with the `pprof` feature. | + +See [Transaction Forwarding](../../docs/tx-forwarding.md) for relay ordering, timeout, and failure behavior. **Examples:** @@ -178,12 +188,13 @@ Use the `--invalid-tx-list-enable` and `--invalid-tx-list-cap` flags (see Custom **Behavior when enabled (default):** - On payload builder panic, all pending transactions are added to the invalid tx list and removed from the mempool — resubmit them after investigating the panic - O(1) hash membership check during transaction validation -- Metrics exposed: `arc_invalid_tx_list_size`, `arc_invalid_tx_list_hits_total`, `arc_invalid_tx_list_inserts_total`, `arc_invalid_tx_list_batch_inserts_total` +- Metrics exposed: `reth_arc_invalid_tx_list_size`, `reth_arc_invalid_tx_list_hits_total`, `reth_arc_invalid_tx_list_inserts_total`, `reth_arc_invalid_tx_list_batch_inserts_total` **Behavior when disabled (`--invalid-tx-list-enable=false`):** - No invalid tx list is created - No metrics are exposed -- On payload builder panic, no action is taken +- No hash quarantine: transactions are not rejected by hash during validation +- On payload builder panic (or an unprocessable transaction), the pending/offending transactions are still removed from the mempool — only the hash quarantine and metrics are skipped **Example:** ```bash @@ -231,8 +242,22 @@ For architectural details, see the [Architecture Guide](../../docs/ARCHITECTURE. The execution layer exposes Prometheus metrics on the configured metrics endpoint (e.g., `http://localhost:9001/metrics`). Key metric prefixes: -- `reth_*` - Core Reth metrics (block processing, sync, etc.) -- `arc_*` - Arc-specific metrics (precompiles, invalid tx list, etc.) +- `reth_*` - Metrics emitted by this Reth-based execution binary +- `reth_arc_*` - Arc-specific execution metrics + +Arc-specific execution metrics: + +| Metric | Type | Description | +|--------|------|-------------| +| `reth_arc_node_version_info` | Gauge | Node version information, labeled by `version` and `git_commit`. | +| `reth_arc_payload_stage_duration_seconds` | Histogram | Payload build stage duration, labeled by `stage`. | +| `reth_arc_payload_total_duration_seconds` | Histogram | Total payload build duration. | +| `reth_arc_payload_build_outcome_total` | Counter | Payload build outcome count, labeled by `outcome`. | +| `reth_arc_tx_denylist_rejection_total` | Counter | Transactions rejected during mempool validation because a transaction address is denylisted. | +| `reth_arc_invalid_tx_list_size` | Gauge | Current number of transaction hashes in the invalid transaction list. | +| `reth_arc_invalid_tx_list_hits_total` | Counter | Transactions rejected because their hash is in the invalid transaction list. | +| `reth_arc_invalid_tx_list_inserts_total` | Counter | Transaction hashes inserted into the invalid transaction list. | +| `reth_arc_invalid_tx_list_batch_inserts_total` | Counter | Batch insert operations into the invalid transaction list. | ## Development diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs index 4d700323..d11cc4fe 100644 --- a/crates/node/src/main.rs +++ b/crates/node/src/main.rs @@ -32,8 +32,7 @@ pub static malloc_conf: &[u8] = b"prof:true,prof_active:false,lg_prof_sample:19\ use arc_evm_node::node::{ArcNode, ArcRpcConfig}; use arc_evm_node::ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT; use arc_execution_config::addresses_denylist::{ - AddressesDenylistConfig, AddressesDenylistConfigError, DEFAULT_DENYLIST_ADDRESS, - DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + AddressesDenylistConfig, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, }; use arc_execution_config::chainspec::{ArcChainSpec, ArcChainSpecParser}; use arc_execution_config::defaults; @@ -43,7 +42,8 @@ use clap::{Args, CommandFactory, FromArgMatches, Parser}; use directories::BaseDirs; use reth_chainspec::EthChainSpec; use reth_ethereum::cli::interface::{Cli as RethCli, Commands}; -use reth_node_core::version::default_extra_data; +use reth_ethereum::cli::parse_duration_from_secs_or_ms; +use reth_node_core::version::default_extra_data_bytes; use reth_rpc_builder::config::RethRpcServerConfig; use reth_rpc_server_types::{RethRpcModule, RpcModuleSelection}; use tracing::info; @@ -83,7 +83,7 @@ impl ArcCli { if let Commands::Node(ref node_cmd) = self.inner.command { // Reject --builder.extradata if user explicitly set it. // Arc uses the extra_data field to store the next block's base fee. - if node_cmd.builder.extra_data != default_extra_data() { + if node_cmd.builder.extra_data != default_extra_data_bytes() { return Err("--builder.extradata is not supported"); } @@ -240,30 +240,6 @@ struct ArcExtraCli { )] wait_for_payload: bool, - /// Enable denylist checks. When false, no denylist lookups. - #[arg( - long = "arc.denylist.enabled", - default_value_t = false, - help_heading = "Arc denylist" - )] - arc_denylist_enabled: bool, - - /// Denylist address (0x-prefixed). Required when --arc.denylist.enabled is true. - #[arg( - long = "arc.denylist.address", - value_name = "ADDRESS", - help_heading = "Arc denylist" - )] - arc_denylist_address: Option, - - /// ERC-7201 base storage slot (0x-prefixed 32 bytes). Required when --arc.denylist.enabled is true. - #[arg( - long = "arc.denylist.storage-slot", - value_name = "SLOT", - help_heading = "Arc denylist" - )] - arc_denylist_storage_slot: Option, - /// Comma-separated addresses to exclude from denylist checks (e.g. for ops recovery). #[arg( long = "arc.denylist.addresses-exclusions", @@ -318,6 +294,35 @@ struct ArcExtraCli { )] arc_rpc_allow_unprotected_txs: bool, + /// Comma-separated upstream RPC URLs for transaction relay, in priority order. + /// + /// Follow nodes relay raw-transaction submission (`eth_sendRawTransaction` + /// and `eth_sendRawTransactionSync`) to these upstreams with failover. + /// Conflicts with Reth's `--rpc.forwarder`. + #[arg( + long = "arc.tx.relays", + env = "ARC_TX_RELAYS", + value_delimiter = ',', + value_name = "URLS", + conflicts_with = "rpc_forwarder", + help_heading = "Arc RPC" + )] + arc_tx_relays: Vec, + + /// Transaction relay timeout: `10s`, `500ms`, or a bare number of seconds. + /// + /// Bounds each relay attempt; when it elapses the relay advances to the next + /// upstream. + #[arg( + long = "arc.tx.relays.timeout", + env = "ARC_TX_RELAYS_TIMEOUT", + value_parser = parse_duration_from_secs_or_ms, + value_name = "DURATION", + default_value = "10s", + help_heading = "Arc RPC" + )] + arc_tx_relays_timeout: std::time::Duration, + /// Interval in seconds between transaction rebroadcast rounds. /// /// Pending transactions are periodically re-announced to all peers to recover @@ -352,28 +357,16 @@ struct ArcExtraCli { pprof_heap_prof: bool, } -/// Build [`AddressesDenylistConfig`] from CLI flags. -/// When enabled, address and storage slot default to genesis constants if not provided. -fn build_addresses_denylist_config(ext: &ArcExtraCli) -> eyre::Result { - use alloy_primitives::{Address, B256}; - - let contract_address = ext - .arc_denylist_address - .as_deref() - .map(|s| s.parse::
()) - .transpose() - .map_err(|e| eyre::eyre!("invalid --arc.denylist.address: {}", e))? - .or(ext.arc_denylist_enabled.then_some(DEFAULT_DENYLIST_ADDRESS)); - - let storage_slot = ext - .arc_denylist_storage_slot - .as_deref() - .map(|s| s.parse::()) - .transpose() - .map_err(|e| eyre::eyre!("invalid --arc.denylist.storage-slot: {}", e))? - .or(ext - .arc_denylist_enabled - .then_some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT)); +/// Build [`AddressesDenylistConfig`] for the chain being run. +/// +/// The contract address and storage slot come from the chain spec, not from CLI flags: the +/// denylist is a protocol requirement, so it is not operator-configurable. Only the +/// ops-recovery exclusions remain operator-supplied. +fn build_addresses_denylist_config( + chain_spec: &ArcChainSpec, + ext: &ArcExtraCli, +) -> eyre::Result { + use alloy_primitives::Address; let addresses_exclusions: Vec
= ext .arc_denylist_addresses_exclusions @@ -382,21 +375,36 @@ fn build_addresses_denylist_config(ext: &ArcExtraCli) -> eyre::Result, _>>() .map_err(|e| eyre::eyre!("invalid --arc.denylist.addresses-exclusions: {}", e))?; - let config = AddressesDenylistConfig::try_new( - ext.arc_denylist_enabled, + // No denylist-free node: an unrecognised chain spec is refused rather than run unchecked. + let contract_address = chain_spec.denylist_address().ok_or_else(|| { + eyre::eyre!( + "no Arc denylist is defined for chain id {}; refusing to start without denylist checks", + chain_spec.chain().id() + ) + })?; + + Ok(AddressesDenylistConfig::new( contract_address, - storage_slot, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, addresses_exclusions, - ) - .map_err(|e| match e { - AddressesDenylistConfigError::MissingContractAddress => { - eyre::eyre!("--arc.denylist.enabled is set but --arc.denylist.address is missing") - } - AddressesDenylistConfigError::MissingStorageSlot => { - eyre::eyre!("--arc.denylist.enabled is set but --arc.denylist.storage-slot is missing") - } - })?; - Ok(config) + )) +} + +/// Validates `--arc.tx.relays` and returns the upstream URLs in priority order. +/// +/// An empty list disables relaying. A non-empty list must contain only valid +/// URLs; any unparseable entry is a startup error. +fn build_tx_relays(ext: &ArcExtraCli) -> eyre::Result> { + ext.arc_tx_relays + .iter() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| { + url::Url::parse(s) + .map(|_| s.to_string()) + .map_err(|e| eyre::eyre!("invalid --arc.tx.relays entry {s:?}: {e}")) + }) + .collect() } /// Namespaces considered safe on a `--public-api` node. @@ -455,12 +463,12 @@ fn parse_max_batch_entries(s: &str) -> Result { const PRESETS_PRUNE_DISTANCE: u64 = 237_600; const FLAG_FULL: &str = "--full"; const FLAG_MINIMAL: &str = "--minimal"; -const FLAG_BLOCK_INTERVAL: &str = "--prune.block-interval=5000"; +const FLAG_BLOCK_INTERVAL: &str = "--prune.block-interval=128"; const FLAG_DATADIR: &str = "--datadir"; /// Registers Arc-specific `DefaultPruningValues` with Reth's global static, then injects /// Arc defaults into argv: -/// - `--prune.block-interval=5000` whenever `--full` or `--minimal` is present +/// - `--prune.block-interval=128` whenever `--full` or `--minimal` is present /// - `--datadir=~/.arc/execution` unless the user already supplied `--datadir` fn init_arc_pruning(argv: I) -> Vec where @@ -494,8 +502,10 @@ where // Collect argv so we can inspect it before rewriting. let mut args: Vec = argv.into_iter().map(Into::into).collect(); - // Inject --prune.block-interval=5000 when --full or --minimal is present, - // unless the user already supplied one. + // Inject --prune.block-interval=128 for `node` when --full or --minimal is + // present, unless already supplied. Other subcommands (e.g. `download`) reuse + // --full/--minimal with a different meaning and reject --prune.block-interval. + let has_node_subcommand = args.get(1).and_then(|a| a.to_str()) == Some("node"); let has_preset = args .iter() .any(|a| matches!(a.to_str(), Some(FLAG_FULL) | Some(FLAG_MINIMAL))); @@ -503,7 +513,7 @@ where a.to_str() .is_some_and(|s| s.starts_with("--prune.block-interval")) }); - if has_preset && !has_explicit_block_interval { + if has_node_subcommand && has_preset && !has_explicit_block_interval { args.push(std::ffi::OsString::from(FLAG_BLOCK_INTERVAL)); } @@ -558,17 +568,13 @@ fn main() { std::process::exit(1); } - let addresses_denylist_config = match &cli.inner.command { - Commands::Node(cmd) => build_addresses_denylist_config(&cmd.ext).unwrap_or_else(|e| { - eprintln!("Error: {e}"); - std::process::exit(1); - }), - _ => AddressesDenylistConfig::default(), - }; if let Err(err) = cli.inner.run_with_components::( arc_components, |mut builder: WithLaunchContext>, ext: ArcExtraCli| async move { + // Resolved from the chain spec, so this must happen after the spec is parsed. + let addresses_denylist_config = + build_addresses_denylist_config(builder.config().chain.as_ref(), &ext)?; let arc_rpc_cfg = ArcRpcConfig::new(ext.enable_arc_rpc, ext.arc_rpc_upstream_url.clone()); let invalid_tx_list_cfg = @@ -601,6 +607,8 @@ fn main() { let allow_unprotected_txs = ext.arc_rpc_allow_unprotected_txs; let max_response_body_size = builder.config().rpc.rpc_max_response_size_bytes(); let max_batch_entries = ext.arc_rpc_max_batch_entries; + let tx_relays = build_tx_relays(&ext)?; + let tx_relay_timeout = ext.arc_tx_relays_timeout; let rebroadcast_interval = std::time::Duration::from_secs(ext.txpool_rebroadcast_interval); let handle = builder @@ -615,6 +623,8 @@ fn main() { max_response_body_size, max_batch_entries, rebroadcast_interval, + tx_relays, + tx_relay_timeout, )) .launch_with_debug_capabilities() .await?; @@ -725,7 +735,7 @@ fn spawn_pprof_server(_bind_address: std::net::SocketAddr, _heap_prof: bool) {} #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{address, b256}; + use alloy_primitives::address; /// Parse CLI args with `patch_node_command_defaults` applied (mirrors production). fn parse_with_arc_defaults(argv: I) -> ArcCli @@ -1000,228 +1010,195 @@ mod tests { } } - #[test] - fn test_arc_denylist_flags_default_values() { - let cli = ArcCli::try_parse_from(["arc-node-execution", "node"]).unwrap(); - if let Commands::Node(node_cmd) = cli.inner.command { - assert!(!node_cmd.ext.arc_denylist_enabled); - assert!(node_cmd.ext.arc_denylist_address.is_none()); - assert!(node_cmd.ext.arc_denylist_storage_slot.is_none()); - assert!(node_cmd.ext.arc_denylist_addresses_exclusions.is_empty()); - } else { - panic!("Expected Node command"); + fn tx_relays_from_args(args: &[&str]) -> eyre::Result> { + let mut argv = vec!["arc-node-execution", "node"]; + argv.extend_from_slice(args); + let cli = ArcCli::try_parse_from(argv).unwrap(); + match &cli.inner.command { + Commands::Node(cmd) => build_tx_relays(&cmd.ext), + _ => panic!("Expected Node command"), } } #[test] - fn test_arc_denylist_flags_custom_values() { - let cli = ArcCli::try_parse_from([ + fn test_build_tx_relays_default_empty() { + assert!(tx_relays_from_args(&[]).unwrap().is_empty()); + } + + #[test] + fn test_build_tx_relays_parses_csv_in_order() { + let relays = + tx_relays_from_args(&["--arc.tx.relays", "http://a:8545,http://b:8545"]).unwrap(); + assert_eq!( + relays, + vec!["http://a:8545".to_string(), "http://b:8545".to_string()] + ); + } + + #[test] + fn test_build_tx_relays_trims_and_skips_blank_entries() { + let relays = tx_relays_from_args(&["--arc.tx.relays", " http://a:8545 , "]).unwrap(); + assert_eq!(relays, vec!["http://a:8545".to_string()]); + } + + #[test] + fn test_build_tx_relays_rejects_invalid_url() { + let err = tx_relays_from_args(&["--arc.tx.relays", "not-a-url"]).unwrap_err(); + assert!(err.to_string().contains("invalid --arc.tx.relays entry")); + } + + #[test] + fn test_tx_relays_conflicts_with_forwarder() { + let result = ArcCli::try_parse_from([ "arc-node-execution", "node", - "--arc.denylist.enabled", - "--arc.denylist.address", - "0x3600000000000000000000000000000000000001", - "--arc.denylist.storage-slot", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--arc.denylist.addresses-exclusions", - "0x1000000000000000000000000000000000000001,0x1000000000000000000000000000000000000002", - ]) - .unwrap(); - if let Commands::Node(node_cmd) = cli.inner.command { - assert!(node_cmd.ext.arc_denylist_enabled); - assert_eq!( - node_cmd.ext.arc_denylist_address.as_deref(), - Some("0x3600000000000000000000000000000000000001") - ); - assert_eq!( - node_cmd.ext.arc_denylist_storage_slot.as_deref(), - Some("0x0000000000000000000000000000000000000000000000000000000000000001") - ); - assert_eq!(node_cmd.ext.arc_denylist_addresses_exclusions.len(), 2); - } else { - panic!("Expected Node command"); + "--arc.tx.relays", + "http://a:8545", + "--rpc.forwarder", + "http://b:8545", + ]); + assert!( + result.is_err(), + "--arc.tx.relays must conflict with --rpc.forwarder" + ); + } + + fn tx_relay_timeout_from_args(args: &[&str]) -> std::time::Duration { + let mut argv = vec!["arc-node-execution", "node"]; + argv.extend_from_slice(args); + let cli = ArcCli::try_parse_from(argv).unwrap(); + match &cli.inner.command { + Commands::Node(cmd) => cmd.ext.arc_tx_relays_timeout, + _ => panic!("Expected Node command"), } } #[test] - fn test_build_addresses_denylist_config_default() { - let cli = ArcCli::try_parse_from(["arc-node-execution", "node"]).unwrap(); - let ext = match &cli.inner.command { - Commands::Node(cmd) => &cmd.ext, - _ => panic!("Expected Node command"), - }; - let cfg = build_addresses_denylist_config(ext).unwrap(); - assert!(!cfg.is_enabled()); + fn test_tx_relay_timeout_default_matches_const() { + assert_eq!( + tx_relay_timeout_from_args(&[]), + arc_evm_node::DEFAULT_TX_RELAY_TIMEOUT + ); } #[test] - fn test_build_addresses_denylist_config_enabled_uses_default_address_and_slot() { - let cli = ArcCli::try_parse_from(["arc-node-execution", "node", "--arc.denylist.enabled"]) - .unwrap(); - let ext = match &cli.inner.command { - Commands::Node(cmd) => &cmd.ext, - _ => panic!("Expected Node command"), - }; - let cfg = build_addresses_denylist_config(ext).unwrap(); - - if let AddressesDenylistConfig::Enabled { - contract_address, - storage_slot, - addresses_exclusions, - } = &cfg - { - assert_eq!(*contract_address, DEFAULT_DENYLIST_ADDRESS); - assert_eq!(*storage_slot, DEFAULT_DENYLIST_ERC7201_BASE_SLOT); - assert!(addresses_exclusions.is_empty()); - } else { - panic!("Expected Enabled variant"); - } + fn test_tx_relay_timeout_parses_suffixed_durations() { + assert_eq!( + tx_relay_timeout_from_args(&["--arc.tx.relays.timeout", "500ms"]), + std::time::Duration::from_millis(500) + ); + assert_eq!( + tx_relay_timeout_from_args(&["--arc.tx.relays.timeout", "15s"]), + std::time::Duration::from_secs(15) + ); } #[test] - fn test_build_addresses_denylist_config_enabled_with_address_uses_default_slot() { - let cli = ArcCli::try_parse_from([ - "arc-node-execution", - "node", - "--arc.denylist.enabled", - "--arc.denylist.address", - "0x3600000000000000000000000000000000000001", - ]) + fn test_tx_relay_timeout_bare_number_is_seconds() { + assert_eq!( + tx_relay_timeout_from_args(&["--arc.tx.relays.timeout", "15"]), + std::time::Duration::from_secs(15) + ); + } + + fn ext_from_args<'a>(args: impl IntoIterator) -> ArcExtraCli { + let cli = ArcCli::try_parse_from( + ["arc-node-execution", "node"] + .into_iter() + .chain(args) + .collect::>(), + ) .unwrap(); - let ext = match &cli.inner.command { - Commands::Node(cmd) => &cmd.ext, + match cli.inner.command { + Commands::Node(cmd) => cmd.ext, _ => panic!("Expected Node command"), - }; - let cfg = build_addresses_denylist_config(ext).unwrap(); - - if let AddressesDenylistConfig::Enabled { - contract_address, - storage_slot, - addresses_exclusions, - } = &cfg - { - assert_eq!( - *contract_address, - address!("0x3600000000000000000000000000000000000001") - ); - assert_eq!(*storage_slot, DEFAULT_DENYLIST_ERC7201_BASE_SLOT); - assert!(addresses_exclusions.is_empty()); - } else { - panic!("Expected Enabled variant"); } } + /// The denylist must not be operator-configurable: disabling it or repointing it at a + /// different list has to require a source change and rebuild, not a CLI flag. #[test] - fn test_build_addresses_denylist_config_enabled_with_both_succeeds() { - let cli = ArcCli::try_parse_from([ - "arc-node-execution", - "node", + fn test_arc_denylist_configuration_flags_are_rejected() { + for flag in [ "--arc.denylist.enabled", "--arc.denylist.address", - "0x3600000000000000000000000000000000000001", "--arc.denylist.storage-slot", - "0x0000000000000000000000000000000000000000000000000000000000000001", - ]) - .unwrap(); - let ext = match &cli.inner.command { - Commands::Node(cmd) => &cmd.ext, - _ => panic!("Expected Node command"), - }; - let cfg = build_addresses_denylist_config(ext).unwrap(); - - if let AddressesDenylistConfig::Enabled { - contract_address, - storage_slot, - addresses_exclusions, - } = &cfg - { - assert_eq!( - *contract_address, - address!("0x3600000000000000000000000000000000000001") - ); - assert_eq!( - *storage_slot, - b256!("0x0000000000000000000000000000000000000000000000000000000000000001") + ] { + assert!( + ArcCli::try_parse_from(["arc-node-execution", "node", flag]).is_err(), + "{flag} must not be accepted" ); - assert!(addresses_exclusions.is_empty()); - } else { - panic!("Expected Enabled variant"); } } #[test] - fn test_build_addresses_denylist_config_invalid_address_rejected() { - let cli = ArcCli::try_parse_from([ - "arc-node-execution", - "node", - "--arc.denylist.address", - "not-an-address", - ]) - .unwrap(); - let ext = match &cli.inner.command { - Commands::Node(cmd) => &cmd.ext, - _ => panic!("Expected Node command"), - }; - let err = build_addresses_denylist_config(ext).unwrap_err(); - assert!(err.to_string().contains("invalid --arc.denylist.address")); + fn test_arc_denylist_exclusions_flag_defaults_empty() { + assert!(ext_from_args([]) + .arc_denylist_addresses_exclusions + .is_empty()); } #[test] - fn test_build_addresses_denylist_config_invalid_storage_slot_rejected() { - let cli = ArcCli::try_parse_from([ - "arc-node-execution", - "node", - "--arc.denylist.storage-slot", - "0x1234", // too short for 32 bytes - ]) - .unwrap(); - let ext = match &cli.inner.command { - Commands::Node(cmd) => &cmd.ext, - _ => panic!("Expected Node command"), - }; - let err = build_addresses_denylist_config(ext).unwrap_err(); - assert!(err - .to_string() - .contains("invalid --arc.denylist.storage-slot")); + fn test_build_addresses_denylist_config_uses_chain_spec_address() { + use arc_execution_config::chainspec::{DEVNET, LOCAL_DEV, MAINNET, TESTNET}; + + for spec in [&*LOCAL_DEV, &*DEVNET, &*TESTNET, &*MAINNET] { + let cfg = build_addresses_denylist_config(spec, &ext_from_args([])).unwrap(); + assert_eq!(cfg.contract_address(), spec.denylist_address().unwrap()); + assert_eq!(cfg.storage_slot(), DEFAULT_DENYLIST_ERC7201_BASE_SLOT); + assert!(cfg.addresses_exclusions().is_empty()); + } } #[test] - fn test_build_addresses_denylist_config_enabled_with_exclusions_succeeds() { - let cli = ArcCli::try_parse_from([ - "arc-node-execution", - "node", - "--arc.denylist.enabled", + fn test_build_addresses_denylist_config_applies_exclusions() { + use arc_execution_config::chainspec::MAINNET; + + let ext = ext_from_args([ "--arc.denylist.addresses-exclusions", "0x3600000000000000000000000000000000000001,0x3600000000000000000000000000000000000002", - ]) - .unwrap(); + ]); + let cfg = build_addresses_denylist_config(&MAINNET, &ext).unwrap(); - let ext = match &cli.inner.command { - Commands::Node(cmd) => &cmd.ext, - _ => panic!("Expected Node command"), - }; - let cfg = build_addresses_denylist_config(ext).unwrap(); - - if let AddressesDenylistConfig::Enabled { - contract_address, - storage_slot, - addresses_exclusions, - } = &cfg - { - assert_eq!(*contract_address, DEFAULT_DENYLIST_ADDRESS); - assert_eq!(*storage_slot, DEFAULT_DENYLIST_ERC7201_BASE_SLOT); - assert_eq!(addresses_exclusions.len(), 2); - assert_eq!( - addresses_exclusions[0], - address!("0x3600000000000000000000000000000000000001") - ); - assert_eq!( - addresses_exclusions[1], - address!("0x3600000000000000000000000000000000000002") - ); - } else { - panic!("Expected Enabled variant"); - } + // Exclusions are ops recovery only; they must not move the contract address. + assert_eq!(cfg.contract_address(), MAINNET.denylist_address().unwrap()); + assert_eq!( + cfg.addresses_exclusions(), + &[ + address!("0x3600000000000000000000000000000000000001"), + address!("0x3600000000000000000000000000000000000002"), + ] + ); + } + + #[test] + fn test_build_addresses_denylist_config_rejects_unknown_chain() { + use alloy_genesis::Genesis; + use arc_execution_config::chainspec::ArcChainSpec; + use reth_chainspec::ChainSpec; + + let mut genesis: Genesis = + serde_json::from_str(include_str!("../../../assets/localdev/genesis.json")).unwrap(); + genesis.config.chain_id = 999999; + let spec = ArcChainSpec::new(ChainSpec::from_genesis(genesis)); + + let err = build_addresses_denylist_config(&spec, &ext_from_args([])).unwrap_err(); + assert!( + err.to_string() + .contains("no Arc denylist is defined for chain id 999999"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_build_addresses_denylist_config_invalid_exclusion_rejected() { + use arc_execution_config::chainspec::MAINNET; + + let ext = ext_from_args(["--arc.denylist.addresses-exclusions", "not-an-address"]); + let err = build_addresses_denylist_config(&MAINNET, &ext).unwrap_err(); + assert!(err + .to_string() + .contains("invalid --arc.denylist.addresses-exclusions")); } #[test] @@ -1355,7 +1332,7 @@ mod tests { } } - /// --full gets --prune.block-interval=5000 injected. + /// --full gets --prune.block-interval=128 injected. #[test] fn test_full_preset_argv_translation() { let argv = init_arc_pruning(["arc-node", "node", "--full"]); @@ -1373,7 +1350,7 @@ mod tests { ); } - /// --minimal gets --prune.block-interval=5000 injected. + /// --minimal gets --prune.block-interval=128 injected. #[test] fn test_minimal_preset_argv_translation() { let argv = init_arc_pruning(["arc-node", "node", "--minimal"]); @@ -1413,6 +1390,43 @@ mod tests { ); } + /// `download --full` must not get --prune.block-interval injected; the flag + /// is a restore profile there, not a node pruning preset. + #[test] + fn test_download_full_does_not_inject_block_interval() { + let argv = init_arc_pruning(["arc-node", "download", "--full"]); + let translated: Vec<_> = argv + .iter() + .map(|s| s.to_str().unwrap().to_owned()) + .collect(); + assert!( + translated.contains(&"--full".to_owned()), + "must retain --full" + ); + assert!( + !translated.iter().any(|s| s == FLAG_BLOCK_INTERVAL), + "must not inject --prune.block-interval on download" + ); + } + + /// `download --minimal` must not get --prune.block-interval injected either. + #[test] + fn test_download_minimal_does_not_inject_block_interval() { + let argv = init_arc_pruning(["arc-node", "download", "--minimal"]); + let translated: Vec<_> = argv + .iter() + .map(|s| s.to_str().unwrap().to_owned()) + .collect(); + assert!( + translated.contains(&"--minimal".to_owned()), + "must retain --minimal" + ); + assert!( + !translated.iter().any(|s| s == FLAG_BLOCK_INTERVAL), + "must not inject --prune.block-interval on download" + ); + } + /// Unrelated args are passed through and --datadir is injected. #[test] fn test_arc_pruning_init_injects_datadir() { diff --git a/crates/node/tests/native_transfer.rs b/crates/node/tests/native_transfer.rs index 7eec4d57..85607897 100644 --- a/crates/node/tests/native_transfer.rs +++ b/crates/node/tests/native_transfer.rs @@ -25,8 +25,6 @@ use alloy_rlp::Bytes; use alloy_rpc_types_trace::geth::call::CallConfig; use alloy_sol_types::SolEvent; use arc_evm::ArcEvm; -use arc_precompiles::NATIVE_COIN_AUTHORITY_ADDRESS; -use common::NativeCoinAuthority; use reth_chainspec::{EthChainSpec, ForkCondition, DEV}; use reth_e2e_test_utils::wallet::Wallet; use reth_ethereum::evm::revm::{inspector::Inspector, interpreter::interpreter::EthInterpreter}; @@ -74,8 +72,7 @@ where let exec: ExecResultAndState = evm.transact_raw(tx.clone()).expect("Tx should be accepted"); assert!(exec.result.is_success(), "execution failed, {exec:?}",); - // Localdev activates Zero5: CALL transfers emit an EIP-7708 Transfer log - // from the system address 0xfffe...fffe instead of NativeCoinTransferred. + // CALL transfers emit an EIP-7708 Transfer log from the system address. let logs = exec.result.logs(); assert_eq!(logs.len(), 1); let log: &Log = &logs[0]; @@ -123,7 +120,7 @@ fn inspect_evm_native_transfer() { let frame = inspector .with_transaction_gas_limit(21_000) .into_geth_builder() - .geth_call_traces(call_config, exec.result.gas_used()); + .geth_call_traces(call_config, exec.result.tx_gas_used()); assert_eq!(frame.from, tx.caller); assert_eq!(frame.to, tx.kind.to().cloned()); @@ -182,8 +179,7 @@ fn evm_native_transfer_zero5_eip7708_log() { "Log should be from EIP-7708 system address" ); - // Decode as ERC-20 Transfer event — same topic signature as NativeCoinTransferred - // but emitted from the EIP-7708 system address instead of the native coin authority. + // Decode as ERC-20 Transfer event emitted from the EIP-7708 system address. let decoded = common::NativeFiatTokenV2_2::Transfer::decode_log(&logs[0]) .expect("Failed to decode EIP-7708 Transfer log"); assert_eq!(decoded.from, sender.address()); @@ -191,9 +187,9 @@ fn evm_native_transfer_zero5_eip7708_log() { assert_eq!(decoded.value, amount); } -/// Pre-Zero5: plain CALL value transfer emits 1 NativeCoinTransferred log. +/// Plain CALL value transfer emits EIP-7708 even without Zero5 metadata active. #[test] -fn evm_native_transfer_pre_zero5_emits_native_coin_transferred() { +fn evm_native_transfer_eip7708_does_not_depend_on_zero5_metadata() { use arc_execution_config::{chainspec::localdev_with_hardforks, hardforks::ArcHardfork}; // Zero4 active but NOT Zero5 let chain_spec = localdev_with_hardforks(&[ @@ -220,20 +216,19 @@ fn evm_native_transfer_pre_zero5_emits_native_coin_transferred() { evm.transact_raw(tx).expect("Tx should be accepted"); assert!(exec.result.is_success(), "execution failed, {exec:?}"); - // Pre-Zero5: should emit 1 NativeCoinTransferred log let logs = exec.result.logs(); assert_eq!( logs.len(), 1, - "Pre-Zero5 should emit 1 NativeCoinTransferred log" + "baseline should emit 1 EIP-7708 Transfer log" ); let log: &Log = &logs[0]; - assert_eq!(log.address, NATIVE_COIN_AUTHORITY_ADDRESS); - let decoded = NativeCoinAuthority::NativeCoinTransferred::decode_log(log) - .expect("Failed to decode NativeCoinTransferred log"); + assert_eq!(log.address, EIP7708_LOG_ADDRESS); + let decoded = common::NativeFiatTokenV2_2::Transfer::decode_log(log) + .expect("Failed to decode EIP-7708 Transfer log"); assert_eq!(decoded.from, sender.address()); assert_eq!(decoded.to, receiver.address()); - assert_eq!(decoded.amount, amount); + assert_eq!(decoded.value, amount); } /// Helper to create a chainspec with Zero4 active but NOT Zero5. @@ -416,18 +411,17 @@ fn evm_native_transfer_zero5_to_zero_address_reverts() { ); } -/// B6: Event ordering is preserved across the Zero5 hardfork boundary. -/// The Transfer log should appear at the same position (index 0) as the old -/// NativeCoinTransferred log for plain CALL value transfers. +/// B6: Event ordering is stable regardless of Zero5 metadata activation. +/// The Transfer log appears at index 0 for plain CALL value transfers. #[test] -fn evm_native_transfer_event_ordering_preserved_across_zero5() { +fn evm_native_transfer_event_ordering_ignores_zero5_metadata() { let sender_idx = WALLET_SENDER_INDEX; let receiver_idx = WALLET_RECEIVER_INDEX; let amount = U256::from(42); - // Pre-Zero5: NativeCoinTransferred - let chain_spec_pre = chainspec_pre_zero5(); - let (mut evm_pre, wallet) = common::setup_evm_with_chainspec(chain_spec_pre); + // Zero5 metadata inactive: EIP-7708 baseline still applies. + let chain_spec_delayed = chainspec_pre_zero5(); + let (mut evm_delayed, wallet) = common::setup_evm_with_chainspec(chain_spec_delayed); let sender = wallet.wallet_gen()[sender_idx].clone(); let receiver = wallet.wallet_gen()[receiver_idx].clone(); let tx_pre = TxEnv { @@ -439,10 +433,10 @@ fn evm_native_transfer_event_ordering_preserved_across_zero5() { gas_price: 0, ..Default::default() }; - let exec_pre = evm_pre + let exec_delayed = evm_delayed .transact_raw(tx_pre) - .expect("Pre-Zero5 tx should be accepted"); - assert!(exec_pre.result.is_success()); + .expect("delayed-Zero5 tx should be accepted"); + assert!(exec_delayed.result.is_success()); // Zero5: EIP-7708 Transfer let chain_spec_z5 = chainspec_with_zero5(); @@ -463,29 +457,28 @@ fn evm_native_transfer_event_ordering_preserved_across_zero5() { .expect("Zero5 tx should be accepted"); assert!(exec_z5.result.is_success()); - let logs_pre = exec_pre.result.logs(); + let logs_delayed = exec_delayed.result.logs(); let logs_z5 = exec_z5.result.logs(); // Same number of logs assert_eq!( - logs_pre.len(), + logs_delayed.len(), logs_z5.len(), "log count should be identical" ); assert_eq!( - logs_pre.len(), + logs_delayed.len(), 1, "plain CALL value transfer should emit exactly 1 log" ); - // Both logs are at index 0 — ordering is preserved - // Pre-Zero5: NativeCoinTransferred from authority address - assert_eq!(logs_pre[0].address, NATIVE_COIN_AUTHORITY_ADDRESS); - let decoded_pre = NativeCoinAuthority::NativeCoinTransferred::decode_log(&logs_pre[0]) - .expect("Failed to decode NativeCoinTransferred"); - assert_eq!(decoded_pre.from, sender.address()); - assert_eq!(decoded_pre.to, receiver.address()); - assert_eq!(decoded_pre.amount, amount); + // Both logs are at index 0 and use the baseline EIP-7708 format. + assert_eq!(logs_delayed[0].address, EIP7708_LOG_ADDRESS); + let decoded_delayed = common::NativeFiatTokenV2_2::Transfer::decode_log(&logs_delayed[0]) + .expect("Failed to decode delayed-Zero5 EIP-7708 Transfer"); + assert_eq!(decoded_delayed.from, sender.address()); + assert_eq!(decoded_delayed.to, receiver.address()); + assert_eq!(decoded_delayed.value, amount); // Zero5: EIP-7708 Transfer from system address assert_eq!(logs_z5[0].address, EIP7708_LOG_ADDRESS); @@ -546,11 +539,10 @@ fn evm_native_transfer_zero5_amsterdam_eip7708_log() { assert_eq!(balance_after + amount, balance_before); } -/// Hardfork boundary test: proves that behavior switches exactly at the Zero5 activation block. -/// Block 9 (pre-Zero5) emits NativeCoinTransferred from NATIVE_COIN_AUTHORITY_ADDRESS; -/// Block 10 (Zero5 active) emits EIP-7708 Transfer from the system address. +/// Hardfork boundary test: proves the EIP-7708 baseline is independent of the +/// Zero5 metadata activation block. #[test] -fn evm_native_transfer_hardfork_boundary_zero5_activation() { +fn evm_native_transfer_hardfork_boundary_keeps_eip7708_baseline() { use arc_execution_config::{chainspec::localdev_with_hardforks, hardforks::ArcHardfork}; use reth_evm::ConfigureEvm; @@ -562,7 +554,7 @@ fn evm_native_transfer_hardfork_boundary_zero5_activation() { let amount = U256::from(42); - // --- Block 9: pre-Zero5 --- + // --- Block 9: Zero5 metadata inactive --- let (evm_config, db, mut evm_env, wallet) = common::setup_evm_env_with_chainspec(chain_spec.clone()); evm_env.block_env.number = U256::from(9); @@ -583,7 +575,7 @@ fn evm_native_transfer_hardfork_boundary_zero5_activation() { let exec_pre = evm_pre .transact_raw(tx_pre) - .expect("Pre-Zero5 tx should be accepted"); + .expect("delayed-Zero5 tx should be accepted"); assert!( exec_pre.result.is_success(), "block 9 tx should succeed: {:?}", @@ -594,17 +586,17 @@ fn evm_native_transfer_hardfork_boundary_zero5_activation() { assert_eq!( logs_pre.len(), 1, - "block 9: should emit exactly 1 NativeCoinTransferred log" + "block 9: should emit exactly 1 EIP-7708 Transfer log" ); assert_eq!( - logs_pre[0].address, NATIVE_COIN_AUTHORITY_ADDRESS, - "block 9: log should come from NativeCoinAuthority" + logs_pre[0].address, EIP7708_LOG_ADDRESS, + "block 9: log should come from EIP-7708 system address" ); - let decoded_pre = NativeCoinAuthority::NativeCoinTransferred::decode_log(&logs_pre[0]) - .expect("Failed to decode NativeCoinTransferred log at block 9"); + let decoded_pre = common::NativeFiatTokenV2_2::Transfer::decode_log(&logs_pre[0]) + .expect("Failed to decode EIP-7708 Transfer log at block 9"); assert_eq!(decoded_pre.from, sender.address()); assert_eq!(decoded_pre.to, receiver.address()); - assert_eq!(decoded_pre.amount, amount); + assert_eq!(decoded_pre.value, amount); // --- Block 10: Zero5 active --- let (evm_config, db, mut evm_env, wallet) = @@ -776,8 +768,8 @@ fn blocklist_enforced_on_inspect_path() { // Both paths should consume the same gas assert_eq!( - exec_no_inspect.result.gas_used(), - exec_inspect.result.gas_used(), + exec_no_inspect.result.tx_gas_used(), + exec_inspect.result.tx_gas_used(), "inspect and non-inspect paths should consume identical gas" ); } @@ -947,7 +939,7 @@ fn evm_native_create2_with_value_zero5_emits_eip7708_log() { let frame = inspector .with_transaction_gas_limit(tx.gas_limit) .into_geth_builder() - .geth_call_traces(call_config, trace_exec.result.gas_used()); + .geth_call_traces(call_config, trace_exec.result.tx_gas_used()); assert_eq!(frame.typ, "CALL"); assert_eq!(frame.to, Some(factory)); assert_eq!( diff --git a/crates/pq-precompile/Cargo.toml b/crates/pq-precompile/Cargo.toml new file mode 100644 index 00000000..2bf792c4 --- /dev/null +++ b/crates/pq-precompile/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "arc-pq-precompile" +version.workspace = true +edition.workspace = true +readme.workspace = true +license.workspace = true +exclude.workspace = true +rust-version.workspace = true +publish.workspace = true +repository.workspace = true + +[dependencies] +alloy-primitives.workspace = true +alloy-sol-types.workspace = true +revm.workspace = true +revm-interpreter.workspace = true +slh-dsa.workspace = true + +[lints] +workspace = true diff --git a/crates/pq-precompile/src/lib.rs b/crates/pq-precompile/src/lib.rs new file mode 100644 index 00000000..c6d5dfa8 --- /dev/null +++ b/crates/pq-precompile/src/lib.rs @@ -0,0 +1,295 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloy_primitives::{address, Address, Bytes}; +use alloy_sol_types::{sol, SolValue}; +use revm::precompile::{PrecompileError, PrecompileHalt, PrecompileOutput}; +use revm_interpreter::gas::KECCAK256WORD; +use revm_interpreter::Gas; +use slh_dsa::{signature::Verifier, Sha2_128s, Signature, VerifyingKey as SlhDsaVerifyingKey}; + +/// PQ precompile address — SLH-DSA-SHA2-128s signature verifier (Zero6-gated). +pub const PQ_ADDRESS: Address = address!("1800000000000000000000000000000000000004"); + +/// Base gas for SLH-DSA-SHA2-128s verification. +/// +/// Conservative relative to the SHA-256 precompile's per-word work anchor. See +/// `crates/precompiles/benches/pq.rs` for the benchmark context comparing this +/// price against SLH-DSA-SHA2-128s verification and 64-byte SHA-256 / KECCAK256 +/// work. +pub const VERIFY_BASE_GAS: u64 = 230_000; + +/// Dynamic gas cost per 32-byte word of message input. +/// +/// SLH-DSA-SHA2-128s hashes the message once via `H_msg` (SHA-256 + MGF1). +/// This is comparable to KECCAK256, so we use the same per-word rate. +pub const GAS_PER_MSG_WORD: u64 = KECCAK256WORD; + +pub const EARLY_REVERT_GAS: u64 = 200; +const VK_LEN: usize = 32; +const SIG_LEN: usize = 7856; + +sol! { + /// Experimental PQ Signature Verifier precompile interface. + interface IPQ { + /// Verify an SLH-DSA-SHA2-128s signature. + /// + /// Since PQ signatures are still very new, we recommend not to solely + /// rely on them for authentication, but pair them with classical + /// signatures. + /// + /// Gas cost: 230,000 base + 6 per 32-byte word of message (same as KECCAK256) + function verifySlhDsaSha2128s(bytes calldata vk, bytes calldata message, bytes calldata sig) external returns (bool isValid); + } +} + +fn revert_message_to_bytes(msg: &str) -> Bytes { + const REVERT_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; + let encoded = msg.abi_encode(); + let mut result = Vec::with_capacity(REVERT_SELECTOR.len().saturating_add(encoded.len())); + result.extend_from_slice(&REVERT_SELECTOR); + result.extend_from_slice(&encoded); + Bytes::from(result) +} + +fn early_revert( + gas_counter: &mut Gas, + reservoir: u64, + msg: &str, +) -> Result { + if !gas_counter.record_regular_cost(EARLY_REVERT_GAS) { + return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, reservoir)); + } + Ok(PrecompileOutput::revert( + gas_counter.used(), + revert_message_to_bytes(msg), + reservoir, + )) +} + +/// Executes the PQ (SLH-DSA-SHA2-128s) precompile. +/// +/// Early-path failures (short input, wrong selector, ABI decode error) charge a 200-gas +/// penalty and return OOG if the caller has insufficient gas — preventing free probing. +pub fn run_pq_precompile( + gas: u64, + data: &[u8], + reservoir: u64, +) -> Result { + use alloy_sol_types::SolCall; + + let mut gas_counter = Gas::new(gas); + + if data.len() < 4 { + return early_revert(&mut gas_counter, reservoir, "Input too short"); + } + + let selector = [data[0], data[1], data[2], data[3]]; + if selector != IPQ::verifySlhDsaSha2128sCall::SELECTOR { + return early_revert(&mut gas_counter, reservoir, "Invalid selector"); + } + + let args = match IPQ::verifySlhDsaSha2128sCall::abi_decode_raw_validate(&data[4..]) { + Ok(args) => args, + Err(_) => return early_revert(&mut gas_counter, reservoir, "Execution reverted"), + }; + + if !gas_counter.record_regular_cost(VERIFY_BASE_GAS) { + return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, reservoir)); + } + + // GAS_PER_MSG_WORD (6) < 32, so the product cannot exceed u64::MAX + #[allow(clippy::arithmetic_side_effects)] + let msg_word_gas = (args.message.len() as u64).div_ceil(32) * GAS_PER_MSG_WORD; + if !gas_counter.record_regular_cost(msg_word_gas) { + return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, reservoir)); + } + + if args.vk.len() != VK_LEN { + return Ok(PrecompileOutput::revert( + gas_counter.used(), + revert_message_to_bytes("Invalid verifying key length"), + reservoir, + )); + } + if args.sig.len() != SIG_LEN { + return Ok(PrecompileOutput::revert( + gas_counter.used(), + revert_message_to_bytes("Invalid signature length"), + reservoir, + )); + } + + let verifying_key = match SlhDsaVerifyingKey::::try_from(args.vk.as_ref()) { + Ok(vk) => vk, + Err(_) => { + return Ok(PrecompileOutput::revert( + gas_counter.used(), + revert_message_to_bytes("Failed to parse verifying key"), + reservoir, + )); + } + }; + let signature = match Signature::::try_from(args.sig.as_ref()) { + Ok(sig) => sig, + Err(_) => { + return Ok(PrecompileOutput::revert( + gas_counter.used(), + revert_message_to_bytes("Failed to parse signature"), + reservoir, + )); + } + }; + + let is_valid = verifying_key + .verify(args.message.as_ref(), &signature) + .is_ok(); + Ok(PrecompileOutput::new( + gas_counter.used(), + is_valid.abi_encode().into(), + reservoir, + )) +} + +#[cfg(test)] +mod tests { + use super::{ + run_pq_precompile, EARLY_REVERT_GAS, GAS_PER_MSG_WORD, IPQ, SIG_LEN, VERIFY_BASE_GAS, + VK_LEN, + }; + use alloy_sol_types::{SolCall, SolValue}; + use revm::precompile::PrecompileOutput; + use slh_dsa::{ + signature::{Keypair, Signer}, + Sha2_128s, SigningKey, + }; + + fn make_keypair() -> SigningKey { + SigningKey::::slh_keygen_internal(&[1u8; 16], &[2u8; 16], &[3u8; 16]) + } + + fn encode_call(vk: &[u8], message: &[u8], sig: &[u8]) -> Vec { + IPQ::verifySlhDsaSha2128sCall { + vk: vk.to_vec().into(), + message: message.to_vec().into(), + sig: sig.to_vec().into(), + } + .abi_encode() + } + + fn decode_bool(output: &PrecompileOutput) -> bool { + bool::abi_decode(&output.bytes).expect("output should be ABI-encoded bool") + } + + #[test] + fn valid_signature_returns_true() { + let sk = make_keypair(); + let vk = sk.verifying_key(); + let msg = b"hello pq world"; + let sig = sk.sign(msg); + + let calldata = encode_call(&vk.to_bytes(), msg, &sig.to_bytes()); + let output = run_pq_precompile(u64::MAX, &calldata, 0).expect("should not fail"); + assert!(output.is_success()); + assert!(decode_bool(&output)); + } + + #[test] + fn invalid_signature_returns_false() { + let sk = make_keypair(); + let vk = sk.verifying_key(); + let sig = sk.sign(b"other message"); + + let calldata = encode_call(&vk.to_bytes(), b"wrong message", &sig.to_bytes()); + let output = run_pq_precompile(u64::MAX, &calldata, 0).expect("should not fail"); + assert!(output.is_success()); + assert!(!decode_bool(&output)); + } + + #[test] + fn short_input_early_revert_charges_penalty() { + let output = + run_pq_precompile(u64::MAX, &[0x01, 0x02], 0).expect("should be Ok(revert), not Err"); + assert!(output.is_revert()); + assert_eq!(output.gas_used, EARLY_REVERT_GAS); + } + + #[test] + fn oog_on_early_revert_when_gas_below_penalty() { + let output = run_pq_precompile(EARLY_REVERT_GAS - 1, &[0x01, 0x02], 0) + .expect("should be Ok(halt), not Err"); + assert!(output.is_halt()); + } + + #[test] + fn wrong_selector_early_revert_charges_penalty() { + let calldata = [0x00u8, 0x00, 0x00, 0x00]; + let output = run_pq_precompile(u64::MAX, &calldata, 0).expect("should be Ok(revert)"); + assert!(output.is_revert()); + assert_eq!(output.gas_used, EARLY_REVERT_GAS); + } + + #[test] + fn malformed_abi_payload_early_revert_charges_penalty() { + let mut calldata = IPQ::verifySlhDsaSha2128sCall::SELECTOR.to_vec(); + calldata.extend_from_slice(&[0x00u8, 0x01]); // too short to ABI-decode + let output = run_pq_precompile(u64::MAX, &calldata, 0).expect("should be Ok(revert)"); + assert!(output.is_revert()); + assert_eq!(output.gas_used, EARLY_REVERT_GAS); + } + + #[test] + fn oog_on_base_gas_returns_halt() { + // Valid calldata structure but gas just below VERIFY_BASE_GAS — fails at base gas charge. + let calldata = encode_call(&[0u8; VK_LEN], &[], &[0u8; SIG_LEN]); + let output = run_pq_precompile(VERIFY_BASE_GAS - 1, &calldata, 0) + .expect("should be Ok(halt), not Err"); + assert!(output.is_halt()); + } + + #[test] + fn vk_wrong_length_reverts_after_base_gas() { + let calldata = encode_call(&[0u8; 16], &[], &[0u8; SIG_LEN]); + let output = + run_pq_precompile(u64::MAX, &calldata, 0).expect("should be Ok(revert), not Err"); + assert!(output.is_revert()); + assert!(output.gas_used >= VERIFY_BASE_GAS); + } + + #[test] + fn sig_wrong_length_reverts_after_base_gas() { + let calldata = encode_call(&[0u8; VK_LEN], &[], &[0u8; 100]); + let output = + run_pq_precompile(u64::MAX, &calldata, 0).expect("should be Ok(revert), not Err"); + assert!(output.is_revert()); + assert!(output.gas_used >= VERIFY_BASE_GAS); + } + + #[test] + fn gas_consumed_matches_formula_for_valid_call() { + let sk = make_keypair(); + let vk = sk.verifying_key(); + let msg = [0u8; 64]; // 2 words + let sig = sk.sign(&msg); + + let calldata = encode_call(&vk.to_bytes(), &msg, &sig.to_bytes()); + let output = run_pq_precompile(u64::MAX, &calldata, 0).expect("should not fail"); + // 2 words * GAS_PER_MSG_WORD + #[allow(clippy::arithmetic_side_effects)] + let expected = VERIFY_BASE_GAS + 2 * GAS_PER_MSG_WORD; + assert_eq!(output.gas_used, expected); + } +} diff --git a/crates/precompiles/Cargo.toml b/crates/precompiles/Cargo.toml index b88e32bf..0c468c28 100644 --- a/crates/precompiles/Cargo.toml +++ b/crates/precompiles/Cargo.toml @@ -24,6 +24,7 @@ alloy-evm.workspace = true alloy-primitives.workspace = true alloy-sol-types.workspace = true arc-execution-config.workspace = true +arc-pq-precompile.workspace = true # local crates reth-ethereum = { workspace = true, features = ["evm"] } diff --git a/crates/precompiles/src/call_from.rs b/crates/precompiles/src/call_from.rs index 328c8764..a54d198f 100644 --- a/crates/precompiles/src/call_from.rs +++ b/crates/precompiles/src/call_from.rs @@ -36,8 +36,9 @@ use crate::subcall::{ SubcallCompletionResult, SubcallContinuationData, SubcallError, SubcallInitResult, SubcallPrecompile, }; -use alloy_primitives::{address, Address, U256}; +use alloy_primitives::{address, Address, B256, U256}; use alloy_sol_types::{sol, SolCall}; +use revm::bytecode::Bytecode; use revm::handler::FrameResult; use revm::interpreter::interpreter_action::{CallInput, CallInputs, CallScheme, CallValue}; use revm_context_interface::cfg::gas; @@ -121,17 +122,22 @@ fn decode_child_call(inputs: &CallInputs) -> Result<(CallInputs, u64), SubcallEr #[allow(clippy::arithmetic_side_effects)] let child_gas_limit = available - (available / 64); + // revm 38 requires `known_bytecode` to be populated by the caller. The precompile + // trait has no journal access, so `ArcEvm::init_subcall` overwrites this placeholder + // with the target's bytecode (following EIP-7702 delegation) before handing the + // frame to revm. `reservoir` is forwarded from the parent frame (EIP-8037). let child_inputs = CallInputs { scheme: CallScheme::Call, target_address: target, bytecode_address: target, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(calldata), gas_limit: child_gas_limit, is_static: false, caller: sender, return_memory_offset: 0..0, + reservoir: inputs.reservoir, }; Ok((child_inputs, overhead)) @@ -190,6 +196,8 @@ impl SubcallPrecompile for CallFromPrecompile { #[cfg(test)] mod tests { use super::*; + use alloy_primitives::Bytes; + use revm::interpreter::{CallOutcome, Gas, InstructionResult, InterpreterResult}; /// Guard against silent upstream changes to the EVM COPY gas cost. /// An unexpected change would alter `abi_decode_gas` and `abi_encode_gas` results, @@ -247,13 +255,14 @@ mod tests { scheme: CallScheme::Call, target_address: CALL_FROM_ADDRESS, bytecode_address: CALL_FROM_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(calldata.into()), gas_limit, is_static: false, caller: address!("c000000000000000000000000000000000000001"), return_memory_offset: 0..0, + reservoir: 0, }; let init_result = precompile @@ -282,4 +291,156 @@ mod tests { "is_static mismatch" ); } + + fn make_call_frame_result(result: InstructionResult, output: Bytes) -> FrameResult { + FrameResult::Call(CallOutcome { + result: InterpreterResult::new(result, output, Gas::new(0)), + memory_offset: 0..0, + was_precompile_called: false, + precompile_call_logs: Default::default(), + }) + } + + fn empty_continuation() -> SubcallContinuationData { + SubcallContinuationData { + state: Box::new(()), + } + } + + #[test] + fn complete_subcall_child_success_encodes_success_true() { + let precompile = CallFromPrecompile; + let output: Bytes = vec![0xde, 0xad, 0xbe, 0xef].into(); + let child = make_call_frame_result(InstructionResult::Return, output.clone()); + + let completion = precompile + .complete_subcall(empty_continuation(), &child) + .expect("Call frame result should produce Ok"); + + let decoded = ICallFrom::callFromCall::abi_decode_returns(&completion.output) + .expect("output should decode as callFrom return"); + assert!(decoded.success, "child Return ⇒ success=true"); + assert_eq!(decoded.returnData.as_ref(), output.as_ref()); + assert_eq!(completion.gas_overhead, abi_encode_gas(output.len())); + assert!( + completion.success, + "precompile completion is always success=true; the caller inspects the encoded bool" + ); + } + + #[test] + fn complete_subcall_child_revert_encodes_success_false() { + let precompile = CallFromPrecompile; + let revert_output: Bytes = vec![0x08, 0xc3, 0x79, 0xa0].into(); + let child = make_call_frame_result(InstructionResult::Revert, revert_output.clone()); + + let completion = precompile + .complete_subcall(empty_continuation(), &child) + .expect("Call frame result with Revert should still produce Ok"); + + let decoded = ICallFrom::callFromCall::abi_decode_returns(&completion.output) + .expect("output should decode as callFrom return"); + assert!(!decoded.success, "child Revert ⇒ success=false"); + assert_eq!(decoded.returnData.as_ref(), revert_output.as_ref()); + assert_eq!(completion.gas_overhead, abi_encode_gas(revert_output.len())); + } + + #[test] + fn complete_subcall_non_call_frame_result_errors() { + use revm_interpreter::CreateOutcome; + + let precompile = CallFromPrecompile; + let create_result = FrameResult::Create(CreateOutcome { + result: InterpreterResult::new(InstructionResult::Return, Bytes::new(), Gas::new(0)), + address: None, + }); + + let result = precompile.complete_subcall(empty_continuation(), &create_result); + assert!( + matches!(result, Err(SubcallError::UnexpectedFrameResult)), + "non-Call FrameResult must yield UnexpectedFrameResult" + ); + } + + fn call_from_inputs(data: Vec, gas_limit: u64) -> CallInputs { + use alloy_primitives::address; + use alloy_sol_types::SolCall; + use revm::interpreter::interpreter_action::{CallInput, CallScheme, CallValue}; + + let calldata = ICallFrom::callFromCall { + sender: address!("e000000000000000000000000000000000000001"), + target: address!("c000000000000000000000000000000000000002"), + data: data.into(), + } + .abi_encode(); + + CallInputs { + scheme: CallScheme::Call, + target_address: CALL_FROM_ADDRESS, + bytecode_address: CALL_FROM_ADDRESS, + known_bytecode: (B256::ZERO, Bytecode::default()), + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.into()), + gas_limit, + is_static: false, + caller: address!("c000000000000000000000000000000000000001"), + return_memory_offset: 0..0, + reservoir: 0, + } + } + + #[test] + fn abi_decode_gas_base_plus_per_word() { + // 0 bytes → base only (div_ceil(0, 32) = 0) + assert_eq!(abi_decode_gas(0), ABI_DECODE_BASE_GAS); + // 1 byte → 1 word + assert_eq!(abi_decode_gas(1), ABI_DECODE_BASE_GAS + gas::COPY); + // 32 bytes → 1 word + assert_eq!(abi_decode_gas(32), ABI_DECODE_BASE_GAS + gas::COPY); + // 33 bytes → 2 words + assert_eq!(abi_decode_gas(33), ABI_DECODE_BASE_GAS + 2 * gas::COPY); + // 64 bytes → 2 words + assert_eq!(abi_decode_gas(64), ABI_DECODE_BASE_GAS + 2 * gas::COPY); + } + + #[test] + fn init_subcall_below_abi_decode_overhead_returns_insufficient_gas() { + let precompile = CallFromPrecompile; + // Empty data: overhead = abi_decode_gas(0). One gas short must revert. + let inputs = call_from_inputs(vec![], abi_decode_gas(0) - 1); + assert!( + matches!( + precompile.init_subcall(&inputs), + Err(SubcallError::InsufficientGas(_)) + ), + "gas_limit one below the ABI decode overhead must return InsufficientGas" + ); + } + + #[test] + fn init_subcall_at_abi_decode_overhead_succeeds() { + let precompile = CallFromPrecompile; + // Exactly the overhead leaves zero gas for the child but still completes init. + let inputs = call_from_inputs(vec![], abi_decode_gas(0)); + let result = precompile + .init_subcall(&inputs) + .expect("gas_limit equal to overhead should succeed"); + assert_eq!(result.gas_overhead, abi_decode_gas(0)); + } + + #[test] + fn init_subcall_below_per_word_overhead_returns_insufficient_gas() { + let precompile = CallFromPrecompile; + // 33 bytes → 2 words → overhead = base + 2*COPY. One gas short must revert. + let data = vec![0u8; 33]; + let overhead = abi_decode_gas(data.len()); + let inputs = call_from_inputs(data, overhead - 1); + assert!( + matches!( + precompile.init_subcall(&inputs), + Err(SubcallError::InsufficientGas(_)) + ), + "nonempty data: gas below base+per-word overhead must return InsufficientGas" + ); + } } diff --git a/crates/precompiles/src/helpers.rs b/crates/precompiles/src/helpers.rs index 13e44934..94857f39 100644 --- a/crates/precompiles/src/helpers.rs +++ b/crates/precompiles/src/helpers.rs @@ -17,18 +17,16 @@ use alloy_evm::EvmInternals; use alloy_primitives::{Address, Bytes, StorageKey, U256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; -use reth_ethereum::evm::revm::precompile::{PrecompileError, PrecompileOutput}; +use arc_execution_config::hardforks::{ArcHardfork, ArcHardforkFlags}; +use reth_ethereum::evm::revm::precompile::{PrecompileError, PrecompileHalt, PrecompileOutput}; use reth_evm::precompiles::PrecompileInput; use revm::context_interface::journaled_state::TransferError; use revm::state::AccountInfo; use revm_context_interface::cfg::gas::CALL_STIPEND; -use revm_context_interface::journaled_state::account::JournaledAccountTr; use revm_interpreter::Gas; use revm_primitives::address; use revm_primitives::constants::KECCAK_EMPTY; -use arc_execution_config::hardforks::{ArcHardfork, ArcHardforkFlags}; - // system addresses in genesis pub const NATIVE_FIAT_TOKEN_ADDRESS: Address = address!("0x3600000000000000000000000000000000000000"); @@ -76,92 +74,123 @@ pub fn revert_message_to_bytes(msg: &str) -> Bytes { /// Gas penalty added to early-path reverts so callers cannot probe precompiles /// for free. /// -/// Pre-Zero6: applied only to ABI decode failures (truncated input, unknown -/// selector) via `new_reverted_with_penalty`. -/// -/// Zero6+: also applied to authorization and validation reverts (unauthorized -/// caller, blocklist, zero address, zero amount, overflow) via +/// Applied to authorization and validation reverts (unauthorized caller, +/// blocklist, zero address, zero amount, overflow) via /// [`new_reverted_with_early_penalty`]. +/// +/// Zero8+: also applied to delegatecall rejections via [`check_delegatecall`]. pub(crate) const PRECOMPILE_EARLY_REVERT_GAS_PENALTY: u64 = 200; -/// Enum to represent either a reverted precompile output or an error +/// Non-success precompile outcomes. +/// +/// revm 38 bakes status (Success/Revert/Halt) into `PrecompileOutput`; fatal +/// errors live in `PrecompileError`. Application-level failures (revert, OOG, +/// other call-local halts) stay in [`Self::Revert`] / [`Self::Error`]. +/// Database and other infrastructure I/O failures use [`Self::Fatal`] so they +/// abort the transaction instead of looking like a failed call. pub(crate) enum PrecompileErrorOrRevert { + /// User-facing revert with ABI-encoded error bytes. Revert(PrecompileOutput), - Error(PrecompileError), + /// Non-fatal halt (out-of-gas, internal failure) surfaced as `PrecompileHalt`. + Error(PrecompileOutput), + /// Unrecoverable I/O failure. Converts to `Err(PrecompileError::Fatal)`. + Fatal(String), } impl PrecompileErrorOrRevert { - pub(crate) fn new_reverted(gas_counter: Gas, msg: &str) -> Self { - Self::Revert(PrecompileOutput::new_reverted( + pub(crate) fn new_reverted(gas_counter: Gas, reservoir: u64, msg: &str) -> Self { + Self::Revert(PrecompileOutput::revert( gas_counter.used(), revert_message_to_bytes(msg), + reservoir, )) } - pub(crate) fn new_reverted_with_penalty(gas_counter: Gas, gas_penalty: u64, msg: &str) -> Self { + pub(crate) fn new_reverted_with_penalty( + gas_counter: Gas, + reservoir: u64, + gas_penalty: u64, + msg: &str, + ) -> Self { let mut gas_with_penalty = gas_counter; - if !gas_with_penalty.record_cost(gas_penalty) { - return Self::Error(PrecompileError::OutOfGas); + if !gas_with_penalty.record_regular_cost(gas_penalty) { + return Self::halt_oog(reservoir); } - Self::Revert(PrecompileOutput::new_reverted( + Self::Revert(PrecompileOutput::revert( gas_with_penalty.used(), revert_message_to_bytes(msg), + reservoir, + )) + } + + pub(crate) fn halt_oog(reservoir: u64) -> Self { + Self::Error(PrecompileOutput::halt(PrecompileHalt::OutOfGas, reservoir)) + } + + #[cfg(test)] + pub(crate) fn halt_other(reservoir: u64, msg: impl Into) -> Self { + Self::Error(PrecompileOutput::halt( + PrecompileHalt::other(msg), + reservoir, )) } + + pub(crate) fn fatal(msg: impl Into) -> Self { + Self::Fatal(msg.into()) + } } /// Gas cost to load an account balance for stateful precompiles. /// -/// Under Zero6+, applies EIP-2929 warm/cold pricing. Before Zero6, a flat -/// cost is charged (matches pre-hardfork behavior for the `balance_incr`, -/// `balance_decr` and `transfer` helpers). -fn account_load_cost(is_cold: bool, hardfork_flags: ArcHardforkFlags) -> u64 { - if hardfork_flags.is_active(ArcHardfork::Zero6) { - if is_cold { - revm_interpreter::gas::COLD_ACCOUNT_ACCESS_COST - } else { - revm_interpreter::gas::WARM_STORAGE_READ_COST - } +/// Applies EIP-2929 warm/cold pricing. +fn account_load_cost(is_cold: bool) -> u64 { + if is_cold { + revm_interpreter::gas::COLD_ACCOUNT_ACCESS_COST } else { - PRECOMPILE_SLOAD_GAS_COST + revm_interpreter::gas::WARM_STORAGE_READ_COST } } -fn storage_io_error(op: &str, e: impl core::fmt::Debug) -> PrecompileErrorOrRevert { - PrecompileErrorOrRevert::Error(PrecompileError::Other( - format!("Storage {op} failed: {e:?}").into(), - )) +fn storage_io_error( + op: &str, + _reservoir: u64, + e: impl core::fmt::Debug, +) -> PrecompileErrorOrRevert { + // `_reservoir` kept so call sites stay unchanged after moving I/O failures + // from halt_other(reservoir, …) to Fatal (which does not carry reservoir). + PrecompileErrorOrRevert::fatal(format!("Storage {op} failed: {e:?}")) } -fn record_zero6_empty_account_creation_cost( +fn record_empty_account_creation_cost( gas_counter: &mut Gas, account_info: &AccountInfo, amount: U256, - hardfork_flags: ArcHardforkFlags, + reservoir: u64, ) -> Result<(), PrecompileErrorOrRevert> { - if hardfork_flags.is_active(ArcHardfork::Zero6) && !amount.is_zero() && account_info.is_empty() - { - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_EMPTY_ACCOUNT_GAS_COST)?; + if !amount.is_zero() && account_info.is_empty() { + record_cost_or_out_of_gas(gas_counter, reservoir, PRECOMPILE_EMPTY_ACCOUNT_GAS_COST)?; } Ok(()) } pub(crate) fn record_cost_or_out_of_gas( gas_counter: &mut Gas, + reservoir: u64, cost: u64, ) -> Result<(), PrecompileErrorOrRevert> { - if !gas_counter.record_cost(cost) { - return Err(PrecompileErrorOrRevert::Error(PrecompileError::OutOfGas)); + if !gas_counter.record_regular_cost(cost) { + return Err(PrecompileErrorOrRevert::halt_oog(reservoir)); } Ok(()) } pub(crate) fn check_gas_remaining( gas_counter: &Gas, + reservoir: u64, cost: u64, ) -> Result<(), PrecompileErrorOrRevert> { if gas_counter.remaining() < cost { - return Err(PrecompileErrorOrRevert::Error(PrecompileError::OutOfGas)); + return Err(PrecompileErrorOrRevert::halt_oog(reservoir)); } Ok(()) } @@ -169,48 +198,36 @@ pub(crate) fn check_gas_remaining( impl From for Result { fn from(val: PrecompileErrorOrRevert) -> Self { match val { - PrecompileErrorOrRevert::Revert(output) => Ok(output.reverted()), - PrecompileErrorOrRevert::Error(error) => Err(error), + PrecompileErrorOrRevert::Revert(output) | PrecompileErrorOrRevert::Error(output) => { + Ok(output) + } + PrecompileErrorOrRevert::Fatal(msg) => Err(PrecompileError::Fatal(msg)), } } } -/// Build a revert that charges [`PRECOMPILE_EARLY_REVERT_GAS_PENALTY`] -/// when Zero6 is active, and zero gas otherwise. -/// /// Use at early-path reverts (unauthorized caller, blocklist, zero address, -/// zero amount, overflow) to give uniform gas accounting under Zero6 and -/// prevent free probing of precompile revert paths. +/// zero amount, overflow) to give uniform gas accounting and prevent free +/// probing of precompile revert paths. pub(crate) fn new_reverted_with_early_penalty( gas_counter: Gas, + reservoir: u64, msg: &str, - hardfork_flags: ArcHardforkFlags, ) -> PrecompileErrorOrRevert { - if hardfork_flags.is_active(ArcHardfork::Zero6) { - PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, - PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - msg, - ) - } else { - PrecompileErrorOrRevert::new_reverted(gas_counter, msg) - } + PrecompileErrorOrRevert::new_reverted_with_penalty( + gas_counter, + reservoir, + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + msg, + ) } /// ABI-decodes raw precompile call arguments. /// -/// Pre-Zero6, this preserves the legacy lenient Alloy decode behavior. Zero6 -/// switches to validated decoding, which rejects non-canonical ABI padding for -/// short static types such as `address`, `bool`, and `uint64`. -pub(crate) fn abi_decode_raw_with_zero6_validation( - input: &[u8], - hardfork_flags: ArcHardforkFlags, -) -> alloy_sol_types::Result { - if hardfork_flags.is_active(ArcHardfork::Zero6) { - C::abi_decode_raw_validate(input) - } else { - C::abi_decode_raw(input) - } +/// Uses validated decoding, which rejects non-canonical ABI padding for short +/// static types such as `address`, `bool`, and `uint64`. +pub(crate) fn abi_decode_raw_validated(input: &[u8]) -> alloy_sol_types::Result { + C::abi_decode_raw_validate(input) } /// Reads a value from storage for stateful precompiles. @@ -220,59 +237,49 @@ pub(crate) fn abi_decode_raw_with_zero6_validation( /// - `address`: The address whose storage to read from /// - `storage_key`: The storage slot to read /// - `gas_counter`: Available gas for this operation -/// - `hardfork`: The current hardfork for gas calculation +/// - `reservoir`: EIP-8037 state-gas reservoir from the precompile call /// /// # Gas Cost -/// - Pre-Zero5: Fixed cost of 2,100 gas units -/// - Zero5+: EIP-2929 warm/cold aware (100 warm, 2100 cold) +/// EIP-2929 warm/cold aware (100 warm, 2100 cold). /// /// # Returns /// - `Ok(Bytes)`: The stored value as big-endian bytes /// - `Err(PrecompileErrorOrRevert)`: If out of gas or storage read fails -/// -/// # Example -/// ```rust,ignore -/// let output = read(internals, precompile_address, StorageKey::ZERO, gas_counter, &hardfork)?; -/// let value = U256::from_be_slice(&output); -/// ``` pub(crate) fn read( internals: &mut EvmInternals, address: Address, storage_key: StorageKey, gas_counter: &mut Gas, - hardfork_flags: ArcHardforkFlags, + reservoir: u64, ) -> Result { - if hardfork_flags.is_active(ArcHardfork::Zero5) { - let mut account = internals - .load_account_mut(address) - .map_err(|e| storage_io_error("read", e))? - .data; - - // Probe slot warmth without DB I/O (skip_cold_load=true). - // Warm → Ok with cached value. Cold → ColdLoadSkipped error, retry after charging. - match account.sload(storage_key.into(), true) { - Ok(slot_load) => { - record_cost_or_out_of_gas( - gas_counter, - revm_interpreter::gas::WARM_STORAGE_READ_COST, - )?; - Ok(slot_load.data.present_value().to_be_bytes_vec().into()) - } - Err(e) if e.is_cold_load_skipped() => { - record_cost_or_out_of_gas(gas_counter, revm_interpreter::gas::COLD_SLOAD_COST)?; - let slot_load = account - .sload(storage_key.into(), false) - .map_err(|e| storage_io_error("read", e))?; - Ok(slot_load.data.present_value().to_be_bytes_vec().into()) - } - Err(e) => Err(storage_io_error("read", e)), + let mut account = internals + .load_account_mut(address) + .map_err(|e| storage_io_error("read", reservoir, e))? + .data; + + // Probe slot warmth without DB I/O (skip_cold_load=true). + // Warm → Ok with cached value. Cold → ColdLoadSkipped error, retry after charging. + match account.sload(storage_key.into(), true) { + Ok(slot_load) => { + record_cost_or_out_of_gas( + gas_counter, + reservoir, + revm_interpreter::gas::WARM_STORAGE_READ_COST, + )?; + Ok(slot_load.data.present_value().to_be_bytes_vec().into()) } - } else { - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SLOAD_GAS_COST)?; - let state_load = internals - .sload(address, storage_key.into()) - .map_err(|e| storage_io_error("read", e))?; - Ok(state_load.data.to_be_bytes_vec().into()) + Err(e) if e.is_cold_load_skipped() => { + record_cost_or_out_of_gas( + gas_counter, + reservoir, + revm_interpreter::gas::COLD_SLOAD_COST, + )?; + let slot_load = account + .sload(storage_key.into(), false) + .map_err(|e| storage_io_error("read", reservoir, e))?; + Ok(slot_load.data.present_value().to_be_bytes_vec().into()) + } + Err(e) => Err(storage_io_error("read", reservoir, e)), } } @@ -301,152 +308,144 @@ fn sstore_base_cost(original: U256, present: U256, new: U256) -> u64 { /// - `storage_key`: The storage slot to write /// - `input`: The value to store (as big-endian bytes) /// - `gas_counter`: Available gas for this operation -/// - `hardfork`: The current hardfork for gas calculation +/// - `reservoir`: EIP-8037 state-gas reservoir from the precompile call /// /// # Gas Cost -/// - Pre-Zero5: Fixed cost of 2,900 gas units -/// - Zero5+: EIP-2929/EIP-2200 aware (varies based on warm/cold and value changes) +/// EIP-2929/EIP-2200 aware (varies based on warm/cold and value changes). /// -/// # EIP-2200 Sentry (Zero6+) +/// # EIP-2200 Sentry /// Mirrors revm's SSTORE opcode behavior: if the remaining gas is less than or /// equal to [`CALL_STIPEND`] (2,300), the call frame fails with `OutOfGas` /// before any storage mutation is journaled. -/// -/// # Returns -/// - `Ok(())`: Success -/// - `Err(PrecompileErrorOrRevert)`: If out of gas or storage write fails -/// -/// # Example -/// ```rust,ignore -/// let new_value = U256::from(42); -/// write( -/// internals, -/// precompile_address, -/// StorageKey::ZERO, -/// &new_value.to_be_bytes_vec(), -/// gas_counter, -/// &hardfork -/// )?; -/// ``` pub(crate) fn write( internals: &mut EvmInternals, address: Address, storage_key: StorageKey, input: &[u8], gas_counter: &mut Gas, - hardfork_flags: ArcHardforkFlags, + reservoir: u64, ) -> Result<(), PrecompileErrorOrRevert> { // EIP-2200 reentrancy sentry: refuse SSTORE when remaining gas does not // exceed the call stipend. - if hardfork_flags.is_active(ArcHardfork::Zero6) && gas_counter.remaining() <= CALL_STIPEND { - return Err(PrecompileErrorOrRevert::Error(PrecompileError::OutOfGas)); + if gas_counter.remaining() <= CALL_STIPEND { + return Err(PrecompileErrorOrRevert::halt_oog(reservoir)); } + // Parse the input as a U256 value let value = U256::from_be_slice(input); - if hardfork_flags.is_active(ArcHardfork::Zero5) { - let mut account = internals - .load_account_mut(address) - .map_err(|e| storage_io_error("write", e))? - .data; - - // Probe slot warmth via sload to get current values for gas calculation. - // This lets us charge all gas before the actual sstore mutation. - let slot = match account.sload(storage_key.into(), true) { - Ok(slot_load) => slot_load.data, - Err(e) if e.is_cold_load_skipped() => { - record_cost_or_out_of_gas(gas_counter, revm_interpreter::gas::COLD_SLOAD_COST)?; - account - .sload(storage_key.into(), false) - .map_err(|e| storage_io_error("write", e))? - .data - } - Err(e) => return Err(storage_io_error("write", e)), - }; - - record_cost_or_out_of_gas( - gas_counter, - sstore_base_cost(slot.original_value, slot.present_value, value), - )?; - - // All gas charged — safe to mutate. Slot is warm from the sload. - account - .sstore(storage_key.into(), value, false) - .map_err(|e| storage_io_error("write", e))?; - Ok(()) - } else { - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SSTORE_GAS_COST)?; - internals - .sstore(address, storage_key.into(), value) - .map_err(|e| storage_io_error("write", e))?; - Ok(()) - } + let mut account = internals + .load_account_mut(address) + .map_err(|e| storage_io_error("write", reservoir, e))? + .data; + + // Probe slot warmth via sload to get current values for gas calculation. + // This lets us charge all gas before the actual sstore mutation. + let slot = match account.sload(storage_key.into(), true) { + Ok(slot_load) => slot_load.data, + Err(e) if e.is_cold_load_skipped() => { + record_cost_or_out_of_gas( + gas_counter, + reservoir, + revm_interpreter::gas::COLD_SLOAD_COST, + )?; + account + .sload(storage_key.into(), false) + .map_err(|e| storage_io_error("write", reservoir, e))? + .data + } + Err(e) => return Err(storage_io_error("write", reservoir, e)), + }; + + record_cost_or_out_of_gas( + gas_counter, + reservoir, + sstore_base_cost(slot.original_value, slot.present_value, value), + )?; + + // All gas charged — safe to mutate. Slot is warm from the sload. + account + .sstore(storage_key.into(), value, false) + .map_err(|e| storage_io_error("write", reservoir, e))?; + Ok(()) } /// Helper to transfer funds between two accounts using the Journal -/// -/// Account gas is charged after the load because `load_account_mut_skip_cold_load` -/// panics on cold accounts in revm ≤36. Storage slot helpers (`read`/`write`) -/// use `skip_cold_load` to charge before I/O; accounts cannot until revm ≥37. +// TODO(NoStory): switch to skip-cold-then-retry (probe via +// `load_account_mut_skip_cold_load`, charge cold gas, then retry), mirroring +// `read`/`write`. Currently performs DB I/O before charging cold-load gas. +// Couple this change with removing the `#[ignore]`d +// `load_account_mut_skip_cold_load_panics_on_cold_account` sentinel test in +// the `tests` module below — it exists to fire exactly when this refactor +// becomes possible (revm 37+). pub(crate) fn transfer( internals: &mut EvmInternals, from: Address, to: Address, amount: U256, gas_counter: &mut Gas, + reservoir: u64, hardfork_flags: ArcHardforkFlags, ) -> Result<(), PrecompileErrorOrRevert> { - let loaded_from_account = internals.load_account(from).map_err(|_| { - PrecompileErrorOrRevert::Error(PrecompileError::Other(ERR_EXECUTION_REVERTED.into())) - })?; + let loaded_from_account = internals + .load_account(from) + .map_err(|e| storage_io_error("load", reservoir, e))?; record_cost_or_out_of_gas( gas_counter, - account_load_cost(loaded_from_account.is_cold, hardfork_flags), + reservoir, + account_load_cost(loaded_from_account.is_cold), )?; // Check that the account can be decremented by the amount - check_can_decr_account(&loaded_from_account.info, amount, gas_counter)?; - - // Mirrors prior balance_decr + balance_incr; Zero6+ uses cold/warm via account_load_cost. - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SSTORE_GAS_COST)?; - - let to_load = internals.load_account(to).map_err(|_| { - PrecompileErrorOrRevert::Error(PrecompileError::Other(ERR_EXECUTION_REVERTED.into())) - })?; - record_cost_or_out_of_gas( + check_can_decr_account( + &loaded_from_account.info, + amount, gas_counter, - account_load_cost(to_load.is_cold, hardfork_flags), + reservoir, + hardfork_flags, )?; - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SSTORE_GAS_COST)?; + // Mirrors prior balance_decr + balance_incr. + record_cost_or_out_of_gas(gas_counter, reservoir, PRECOMPILE_SSTORE_GAS_COST)?; + + let to_load = internals + .load_account(to) + .map_err(|e| storage_io_error("load", reservoir, e))?; + record_cost_or_out_of_gas(gas_counter, reservoir, account_load_cost(to_load.is_cold))?; + + record_cost_or_out_of_gas(gas_counter, reservoir, PRECOMPILE_SSTORE_GAS_COST)?; - if hardfork_flags.is_active(ArcHardfork::Zero5) && to_load.is_selfdestructed() { + if to_load.is_selfdestructed() { return Err(PrecompileErrorOrRevert::new_reverted( *gas_counter, + reservoir, ERR_SELFDESTRUCTED_BALANCE_INCREASED, )); } - record_zero6_empty_account_creation_cost(gas_counter, &to_load.info, amount, hardfork_flags)?; + record_empty_account_creation_cost(gas_counter, &to_load.info, amount, reservoir)?; - let transfer_result = internals.transfer(from, to, amount).map_err(|_e| { - PrecompileErrorOrRevert::new_reverted(*gas_counter, ERR_EXECUTION_REVERTED) - })?; + let transfer_result = internals + .transfer(from, to, amount) + .map_err(|e| storage_io_error("transfer", reservoir, e))?; match transfer_result { None => Ok(()), Some(error) => match error { - // This should never be hit, due to the check prior + // Pre-empted by `check_can_decr_account` above. TransferError::OutOfFunds => Err(PrecompileErrorOrRevert::new_reverted( *gas_counter, + reservoir, ERR_INSUFFICIENT_FUNDS, )), TransferError::OverflowPayment => Err(PrecompileErrorOrRevert::new_reverted( *gas_counter, + reservoir, ERR_OVERFLOW, )), TransferError::CreateCollision => Err(PrecompileErrorOrRevert::new_reverted( *gas_counter, + reservoir, ERR_EXECUTION_REVERTED, )), }, @@ -454,25 +453,26 @@ pub(crate) fn transfer( } /// Helper to increment an account's balance by an amount using the Journal +// TODO(NoStory): see the matching note above `fn transfer`. Same refactor +// applies here. Couple with removing the `#[ignore]`d sentinel test in +// `tests`. pub(crate) fn balance_incr( internals: &mut EvmInternals, to: Address, amount: U256, gas_counter: &mut Gas, - hardfork_flags: ArcHardforkFlags, + reservoir: u64, ) -> Result<(), PrecompileErrorOrRevert> { // Balance check, but doesn't touch state - let account = internals.load_account(to).map_err(|_| { - PrecompileErrorOrRevert::Error(PrecompileError::Other(ERR_EXECUTION_REVERTED.into())) - })?; - record_cost_or_out_of_gas( - gas_counter, - account_load_cost(account.is_cold, hardfork_flags), - )?; + let account = internals + .load_account(to) + .map_err(|e| storage_io_error("load", reservoir, e))?; + record_cost_or_out_of_gas(gas_counter, reservoir, account_load_cost(account.is_cold))?; - if hardfork_flags.is_active(ArcHardfork::Zero5) && account.is_selfdestructed() { + if account.is_selfdestructed() { return Err(PrecompileErrorOrRevert::new_reverted( *gas_counter, + reservoir, ERR_SELFDESTRUCTED_BALANCE_INCREASED, )); } @@ -482,48 +482,61 @@ pub(crate) fn balance_incr( .checked_add(amount) .ok_or(PrecompileErrorOrRevert::new_reverted( *gas_counter, + reservoir, ERR_OVERFLOW, ))?; // Update state - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SSTORE_GAS_COST)?; - record_zero6_empty_account_creation_cost(gas_counter, &account.info, amount, hardfork_flags)?; - internals.balance_incr(to, amount).map_err(|_| { - PrecompileErrorOrRevert::Error(PrecompileError::Other(ERR_EXECUTION_REVERTED.into())) - })?; + record_cost_or_out_of_gas(gas_counter, reservoir, PRECOMPILE_SSTORE_GAS_COST)?; + record_empty_account_creation_cost(gas_counter, &account.info, amount, reservoir)?; + internals + .balance_incr(to, amount) + .map_err(|e| storage_io_error("write", reservoir, e))?; Ok(()) } /// Helper to decrement an account's balance by an amount using the Journal +// TODO(NoStory): see the matching note above `fn transfer`. Same refactor +// applies here. Couple with removing the `#[ignore]`d sentinel test in +// `tests`. pub(crate) fn balance_decr( internals: &mut EvmInternals, from: Address, amount: U256, gas_counter: &mut Gas, + reservoir: u64, hardfork_flags: ArcHardforkFlags, ) -> Result<(), PrecompileErrorOrRevert> { - let loaded_from_account = internals.load_account(from).map_err(|_| { - PrecompileErrorOrRevert::Error(PrecompileError::Other(ERR_EXECUTION_REVERTED.into())) - })?; + let loaded_from_account = internals + .load_account(from) + .map_err(|e| storage_io_error("load", reservoir, e))?; record_cost_or_out_of_gas( gas_counter, - account_load_cost(loaded_from_account.is_cold, hardfork_flags), + reservoir, + account_load_cost(loaded_from_account.is_cold), )?; // Check that the account can be decremented by the amount - check_can_decr_account(&loaded_from_account.info, amount, gas_counter)?; + check_can_decr_account( + &loaded_from_account.info, + amount, + gas_counter, + reservoir, + hardfork_flags, + )?; // Perform the decrement - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SSTORE_GAS_COST)?; - let mut account = internals.load_account_mut(from).map_err(|_| { - PrecompileErrorOrRevert::Error(PrecompileError::Other(ERR_EXECUTION_REVERTED.into())) - })?; + record_cost_or_out_of_gas(gas_counter, reservoir, PRECOMPILE_SSTORE_GAS_COST)?; + let mut account = internals + .load_account_mut(from) + .map_err(|e| storage_io_error("load", reservoir, e))?; - // False is only returned if insufficient funds, which should theoretically anyways never be reached due to the prior check + // False only returned on insufficient funds; prior check_can_decr_account makes this unreachable. if !account.decr_balance(amount) { return Err(PrecompileErrorOrRevert::new_reverted( *gas_counter, + reservoir, ERR_INSUFFICIENT_FUNDS, )); } @@ -541,6 +554,7 @@ pub(crate) fn check_staticcall( gas_counter.spend_all(); return Err(PrecompileErrorOrRevert::new_reverted( *gas_counter, + precompile_input.reservoir, ERR_STATE_CHANGE_DURING_STATIC_CALL, )); } @@ -552,30 +566,49 @@ pub(crate) fn check_delegatecall( precompile_address: Address, precompile_input: &PrecompileInput, gas_counter: &Gas, + hardfork_flags: ArcHardforkFlags, ) -> Result<(), PrecompileErrorOrRevert> { if precompile_input.target_address != precompile_address || precompile_input.bytecode_address != precompile_address { - return Err(PrecompileErrorOrRevert::new_reverted( - *gas_counter, - ERR_DELEGATE_CALL_NOT_ALLOWED, - )); + return Err(if hardfork_flags.is_active(ArcHardfork::Zero8) { + PrecompileErrorOrRevert::new_reverted_with_penalty( + *gas_counter, + precompile_input.reservoir, + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + ERR_DELEGATE_CALL_NOT_ALLOWED, + ) + } else { + PrecompileErrorOrRevert::new_reverted( + *gas_counter, + precompile_input.reservoir, + ERR_DELEGATE_CALL_NOT_ALLOWED, + ) + }); } Ok(()) } -/// Helper to determine if an account can be decremented by an amount -/// Decrements gas counter if account would be emptied +/// Helper to determine if an account can be decremented by an amount. +/// Reverts on insufficient funds. Pre-Zero8 also reverts if the decrement would +/// empty the account; Zero8+ permits draining to empty, letting EIP-161 clear it. pub(crate) fn check_can_decr_account( loaded_account_info: &AccountInfo, amount: U256, gas_counter: &mut Gas, + reservoir: u64, + hardfork_flags: ArcHardforkFlags, ) -> Result<(), PrecompileErrorOrRevert> { // Check that the account has sufficient balance let from_account_balance = loaded_account_info.balance.checked_sub(amount).ok_or( - PrecompileErrorOrRevert::new_reverted(*gas_counter, ERR_INSUFFICIENT_FUNDS), + PrecompileErrorOrRevert::new_reverted(*gas_counter, reservoir, ERR_INSUFFICIENT_FUNDS), )?; + // Zero8+: allow draining an account to empty; EIP-161 clears it at commit. + if hardfork_flags.is_active(ArcHardfork::Zero8) { + return Ok(()); + } + // Check that the account would not be emptied if this transfer goes through let from_account_is_empty = from_account_balance.is_zero() && loaded_account_info.nonce == 0 @@ -583,9 +616,10 @@ pub(crate) fn check_can_decr_account( || loaded_account_info.code_hash().is_zero()); if from_account_is_empty { - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SSTORE_GAS_COST)?; + record_cost_or_out_of_gas(gas_counter, reservoir, PRECOMPILE_SSTORE_GAS_COST)?; return Err(PrecompileErrorOrRevert::new_reverted( *gas_counter, + reservoir, ERR_CLEAR_EMPTY, )); } @@ -599,6 +633,7 @@ pub(crate) fn emit_event( address: Address, event: &Event, gas_counter: &mut Gas, + reservoir: u64, ) -> Result<(), PrecompileErrorOrRevert> { let data = event.encode_log_data(); @@ -607,7 +642,7 @@ pub(crate) fn emit_event( let log_gas = LOG_BASE_COST .saturating_add(topic_gas) .saturating_add(data_gas); - record_cost_or_out_of_gas(gas_counter, log_gas)?; + record_cost_or_out_of_gas(gas_counter, reservoir, log_gas)?; let log = revm::primitives::Log { address, data }; @@ -619,6 +654,7 @@ pub(crate) fn emit_event( pub(crate) mod test_utils { use alloy_primitives::{Address, B256, U256}; use revm::database_interface::{DBErrorMarker, Database, DatabaseRef}; + use revm::interpreter::InterpreterResult; use revm::state::{AccountInfo, Bytecode}; use std::cell::Cell; @@ -713,12 +749,116 @@ pub(crate) mod test_utils { Ok(alloy_primitives::keccak256(number.to_string().as_bytes())) } } + + /// Database that fails account or storage loads so tests can prove I/O + /// errors abort via `PrecompileError::Fatal` instead of a call-local halt. + #[derive(Debug, Clone, Copy)] + pub(crate) enum FailingDbMode { + AccountLoad, + Storage, + } + + #[derive(Debug, Clone, Copy)] + pub(crate) struct FailingDB { + mode: FailingDbMode, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct FailingDBError; + + impl core::fmt::Display for FailingDBError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "FailingDBError") + } + } + + impl core::error::Error for FailingDBError {} + impl DBErrorMarker for FailingDBError {} + + type FailingContext = revm::Context< + revm::context::BlockEnv, + revm::context::TxEnv, + revm::context::CfgEnv, + FailingDB, + revm::context::Journal, + >; + + impl FailingDB { + pub(crate) fn context(mode: FailingDbMode) -> FailingContext { + revm::context::Context::new(Self { mode }, revm_primitives::hardfork::SpecId::default()) + } + } + + impl Database for FailingDB { + type Error = FailingDBError; + + fn basic(&mut self, _address: Address) -> Result, Self::Error> { + match self.mode { + FailingDbMode::AccountLoad => Err(FailingDBError), + FailingDbMode::Storage => Ok(None), + } + } + + fn code_by_hash(&mut self, _code_hash: B256) -> Result { + Ok(Bytecode::default()) + } + + fn storage(&mut self, _address: Address, _index: U256) -> Result { + Err(FailingDBError) + } + + fn block_hash(&mut self, number: u64) -> Result { + Ok(alloy_primitives::keccak256(number.to_string().as_bytes())) + } + } + + impl DatabaseRef for FailingDB { + type Error = FailingDBError; + + fn basic_ref(&self, _address: Address) -> Result, Self::Error> { + match self.mode { + FailingDbMode::AccountLoad => Err(FailingDBError), + FailingDbMode::Storage => Ok(None), + } + } + + fn code_by_hash_ref(&self, _code_hash: B256) -> Result { + Ok(Bytecode::default()) + } + + fn storage_ref(&self, _address: Address, _index: U256) -> Result { + Err(FailingDBError) + } + + fn block_hash_ref(&self, number: u64) -> Result { + Ok(alloy_primitives::keccak256(number.to_string().as_bytes())) + } + } + + pub(crate) fn assert_provider_db_error_is_fatal( + result: Result, String>, + name: &str, + ) { + match result { + Err(msg) => { + assert!( + msg.to_ascii_lowercase().contains("fatal"), + "{name}: expected fatal precompile error, got {msg}" + ); + } + Ok(Some(res)) => panic!( + "{name}: DB error must abort the provider, got {:?}", + res.result + ), + Ok(None) => panic!("{name}: expected precompile to run"), + } + } } #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{address, U256}; + use alloy_primitives::U256; use alloy_sol_types::sol; use revm_primitives::B256; @@ -726,11 +866,17 @@ mod tests { /// panics on cold accounts, making it unusable for the probe-then-charge /// pattern we use for storage slots via `JournaledAccountTr::sload/sstore`. /// - /// This is fixed in revm 37+ (bluealloy/revm#3477). When upgrading past - /// revm 36, this test should start failing (no longer panics). At that - /// point, switch `transfer`/`balance_incr`/`balance_decr` to the - /// skip-cold-then-retry pattern used by `read`/`write`, and remove this test. + /// This was fixed in revm 37+ (bluealloy/revm#3477). On revm 38 the call + /// no longer panics, so the `#[should_panic]` assertion fails. Test is + /// `#[ignore]`d as a permanent breadcrumb until the follow-up refactor + /// switches `transfer`/`balance_incr`/`balance_decr` to the + /// skip-cold-then-retry pattern used by `read`/`write`. Delete this test + /// as part of that refactor. + /// + /// TODO(NoStory): follow-up PR — switch the three account helpers to + /// skip-cold-then-retry; remove this test. #[test] + #[ignore = "revm 38 fixed the underlying panic; superseded by the helper refactor TODO"] #[should_panic(expected = "Expected DBError")] fn load_account_mut_skip_cold_load_panics_on_cold_account() { use revm::context_interface::journaled_state::JournalTr; @@ -782,77 +928,42 @@ mod tests { } #[test] - fn abi_decode_raw_validation_is_zero6_gated_for_address_padding() { + fn abi_decode_raw_validation_rejects_address_padding() { let mut input = [0u8; 32]; input[..12].fill(0x11); input[12] = 0xaa; - let pre_zero6 = abi_decode_raw_with_zero6_validation::( - &input, - ArcHardforkFlags::with(&[ArcHardfork::Zero5]), - ) - .expect("pre-Zero6 decode preserves legacy lenient padding"); - assert_eq!( - pre_zero6.account, - address!("aa00000000000000000000000000000000000000") - ); - - let zero6 = abi_decode_raw_with_zero6_validation::( - &input, - ArcHardforkFlags::with(&[ArcHardfork::Zero6]), - ); - assert!(zero6.is_err(), "Zero6 rejects non-zero address padding"); + let result = abi_decode_raw_validated::(&input); + assert!(result.is_err(), "non-zero address padding must be rejected"); } #[test] - fn abi_decode_raw_validation_is_zero6_gated_for_uint64_padding() { + fn abi_decode_raw_validation_rejects_uint64_padding() { let mut input = [0u8; 32]; input[0] = 0x11; input[31] = 42; - let pre_zero6 = abi_decode_raw_with_zero6_validation::( - &input, - ArcHardforkFlags::with(&[ArcHardfork::Zero5]), - ) - .expect("pre-Zero6 decode preserves legacy lenient padding"); - assert_eq!(pre_zero6.value, 42); - - let zero6 = abi_decode_raw_with_zero6_validation::( - &input, - ArcHardforkFlags::with(&[ArcHardfork::Zero6]), - ); - assert!(zero6.is_err(), "Zero6 rejects non-zero uint64 padding"); + let result = abi_decode_raw_validated::(&input); + assert!(result.is_err(), "non-zero uint64 padding must be rejected"); } #[test] - fn zero6_empty_account_creation_cost_charges_only_for_nonzero_empty_accounts() { - let zero6 = ArcHardforkFlags::with(&[ArcHardfork::Zero6]); - let pre_zero6 = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); - + fn empty_account_creation_cost_charges_only_for_nonzero_empty_accounts() { let mut gas_counter = Gas::new(100_000); - assert!(record_zero6_empty_account_creation_cost( - &mut gas_counter, - &AccountInfo::default(), - U256::from(1), - pre_zero6, - ) - .is_ok()); - assert_eq!(gas_counter.used(), 0); - - assert!(record_zero6_empty_account_creation_cost( + assert!(record_empty_account_creation_cost( &mut gas_counter, &AccountInfo::default(), U256::ZERO, - zero6, + 0, ) .is_ok()); assert_eq!(gas_counter.used(), 0); - assert!(record_zero6_empty_account_creation_cost( + assert!(record_empty_account_creation_cost( &mut gas_counter, &AccountInfo::default(), U256::from(1), - zero6, + 0, ) .is_ok()); assert_eq!(gas_counter.used(), PRECOMPILE_EMPTY_ACCOUNT_GAS_COST); @@ -871,11 +982,11 @@ mod tests { ..Default::default() }, ] { - assert!(record_zero6_empty_account_creation_cost( + assert!(record_empty_account_creation_cost( &mut gas_counter, &non_empty_account, U256::from(1), - zero6, + 0, ) .is_ok()); assert_eq!(gas_counter.used(), PRECOMPILE_EMPTY_ACCOUNT_GAS_COST); @@ -883,16 +994,16 @@ mod tests { } #[test] - fn zero6_empty_account_creation_cost_errors_when_out_of_gas() { + fn empty_account_creation_cost_errors_when_out_of_gas() { let mut gas_counter = Gas::new(PRECOMPILE_EMPTY_ACCOUNT_GAS_COST.saturating_sub(1)); assert!(matches!( - record_zero6_empty_account_creation_cost( + record_empty_account_creation_cost( &mut gas_counter, &AccountInfo::default(), U256::from(1), - ArcHardforkFlags::with(&[ArcHardfork::Zero6]), + 0, ), - Err(PrecompileErrorOrRevert::Error(PrecompileError::OutOfGas)) + Err(PrecompileErrorOrRevert::Error(_)) )); } @@ -905,6 +1016,7 @@ mod tests { nonce: u64, code_hash: [u8; 32], decr_amount: U256, + zero8: bool, expect_revert: bool, revert_message: &'static str, expected_gas_used: u64, @@ -917,6 +1029,7 @@ mod tests { nonce: 1, code_hash: *KECCAK_EMPTY, decr_amount: U256::from(101), + zero8: false, expect_revert: true, revert_message: ERR_INSUFFICIENT_FUNDS, expected_gas_used: 0, @@ -927,6 +1040,7 @@ mod tests { nonce: 0, code_hash: *KECCAK_EMPTY, decr_amount: U256::from(101), + zero8: false, expect_revert: true, revert_message: ERR_INSUFFICIENT_FUNDS, expected_gas_used: 0, @@ -937,6 +1051,7 @@ mod tests { nonce: 0, code_hash: B256::ZERO.into(), decr_amount: U256::from(101), + zero8: false, expect_revert: true, revert_message: ERR_INSUFFICIENT_FUNDS, expected_gas_used: 0, @@ -947,6 +1062,7 @@ mod tests { nonce: 0, code_hash: *KECCAK_EMPTY, decr_amount: U256::from(100), + zero8: false, expect_revert: true, revert_message: ERR_CLEAR_EMPTY, expected_gas_used: PRECOMPILE_SSTORE_GAS_COST, @@ -957,6 +1073,7 @@ mod tests { nonce: 0, code_hash: B256::ZERO.into(), decr_amount: U256::from(100), + zero8: false, expect_revert: true, revert_message: ERR_CLEAR_EMPTY, expected_gas_used: PRECOMPILE_SSTORE_GAS_COST, @@ -967,6 +1084,7 @@ mod tests { nonce: 1, code_hash: *KECCAK_EMPTY, decr_amount: U256::from(100), + zero8: false, expect_revert: false, revert_message: "", expected_gas_used: 0, @@ -977,6 +1095,7 @@ mod tests { nonce: 0, code_hash: B256::from([1u8; 32]).into(), decr_amount: U256::from(100), + zero8: false, expect_revert: false, revert_message: "", expected_gas_used: 0, @@ -987,10 +1106,44 @@ mod tests { nonce: 0, code_hash: *KECCAK_EMPTY, decr_amount: U256::from(99), + zero8: false, + expect_revert: false, + revert_message: "", + expected_gas_used: 0, + }, + TestCase { + name: "zero8_allows_draining_empty_account_with_KECCAK_EMPTY_code_hash", + balance: U256::from(100), + nonce: 0, + code_hash: *KECCAK_EMPTY, + decr_amount: U256::from(100), + zero8: true, expect_revert: false, revert_message: "", expected_gas_used: 0, }, + TestCase { + name: "zero8_allows_draining_empty_account_with_zero_code_hash", + balance: U256::from(100), + nonce: 0, + code_hash: B256::ZERO.into(), + decr_amount: U256::from(100), + zero8: true, + expect_revert: false, + revert_message: "", + expected_gas_used: 0, + }, + TestCase { + name: "zero8_still_reverts_insufficient_funds", + balance: U256::from(100), + nonce: 0, + code_hash: *KECCAK_EMPTY, + decr_amount: U256::from(101), + zero8: true, + expect_revert: true, + revert_message: ERR_INSUFFICIENT_FUNDS, + expected_gas_used: 0, + }, ]; for tc in testcases { @@ -1001,8 +1154,19 @@ mod tests { code_hash: tc.code_hash.into(), ..Default::default() }; + let hardfork_flags = if tc.zero8 { + ArcHardforkFlags::with(&[ArcHardfork::Zero8]) + } else { + ArcHardforkFlags::default() + }; - let result = check_can_decr_account(&account_info, tc.decr_amount, &mut gas_counter); + let result = check_can_decr_account( + &account_info, + tc.decr_amount, + &mut gas_counter, + 0, + hardfork_flags, + ); if tc.expect_revert { assert!( result.is_err(), @@ -1023,6 +1187,9 @@ mod tests { PrecompileErrorOrRevert::Error(_) => { panic!("Test case {}: expected revert but got error", tc.name); } + PrecompileErrorOrRevert::Fatal(_) => { + panic!("Test case {}: expected revert but got fatal", tc.name); + } } assert_eq!( gas_counter.used(), @@ -1047,28 +1214,233 @@ mod tests { } #[test] - fn from_precompile_error_or_revert_revert_sets_reverted_flag() { + fn from_precompile_error_or_revert_revert_keeps_output() { let revert_bytes = revert_message_to_bytes("test revert"); - let err_or_revert = - PrecompileErrorOrRevert::Revert(PrecompileOutput::new(1_000, revert_bytes.clone())); + let err_or_revert = PrecompileErrorOrRevert::Revert(PrecompileOutput::revert( + 1_000, + revert_bytes.clone(), + 0, + )); let result: Result = err_or_revert.into(); let output = result.expect("Revert variant must convert to Ok(PrecompileOutput)"); assert!( - output.reverted, - "canonical From impl must set reverted flag on Revert variant" + output.is_revert(), + "Revert variant must preserve Revert status" ); assert_eq!(output.gas_used, 1_000); assert_eq!(output.bytes, revert_bytes); } #[test] - fn from_precompile_error_or_revert_error_maps_to_err() { - let err_or_revert = PrecompileErrorOrRevert::Error(PrecompileError::OutOfGas); + fn from_precompile_error_or_revert_error_keeps_halt() { + let err_or_revert = PrecompileErrorOrRevert::halt_oog(0); + + let result: Result = err_or_revert.into(); + + let output = + result.expect("Error variant must convert to Ok(PrecompileOutput) under revm 38"); + assert!(output.is_halt(), "Error variant must preserve Halt status"); + assert_eq!( + output.halt_reason(), + Some(&PrecompileHalt::OutOfGas), + "halt reason must round-trip" + ); + } + + #[test] + fn from_precompile_error_or_revert_halt_other_is_not_fatal() { + let err_or_revert = PrecompileErrorOrRevert::halt_other(0, "non-io halt"); let result: Result = err_or_revert.into(); - assert!(matches!(result, Err(PrecompileError::OutOfGas))); + let output = result.expect("non-fatal halt must remain Ok(PrecompileOutput)"); + assert!(output.is_halt()); + assert_eq!( + output.halt_reason(), + Some(&PrecompileHalt::other("non-io halt")), + ); + } + + #[test] + fn from_precompile_error_or_revert_fatal_aborts() { + assert_converts_to_fatal(PrecompileErrorOrRevert::fatal( + "Storage read failed: FailingDBError", + )); + } + + fn assert_converts_to_fatal(err: PrecompileErrorOrRevert) { + let result: Result = err.into(); + match result { + Err(PrecompileError::Fatal(msg)) => { + assert!( + msg.contains("Storage"), + "fatal message must identify the storage I/O failure, got {msg}" + ); + } + Ok(output) => panic!( + "DB error must abort via PrecompileError::Fatal, not Ok({:?})", + output.status + ), + Err(other) => panic!("expected PrecompileError::Fatal, got {other:?}"), + } + } + + fn with_failing_internals( + mode: test_utils::FailingDbMode, + f: impl FnOnce(&mut EvmInternals<'_>) -> R, + ) -> R { + let mut ctx = test_utils::FailingDB::context(mode); + let mut internals = EvmInternals::from_context(&mut ctx); + f(&mut internals) + } + + #[test] + fn read_account_load_error_is_fatal() { + with_failing_internals(test_utils::FailingDbMode::AccountLoad, |internals| { + let mut gas_counter = Gas::new(100_000); + let err = read( + internals, + address!("dead000000000000000000000000000000000001"), + StorageKey::ZERO, + &mut gas_counter, + 0, + ) + .expect_err("account load failure must not succeed"); + assert_converts_to_fatal(err); + }); + } + + #[test] + fn read_storage_error_is_fatal() { + with_failing_internals(test_utils::FailingDbMode::Storage, |internals| { + let mut gas_counter = Gas::new(100_000); + let err = read( + internals, + address!("dead000000000000000000000000000000000001"), + StorageKey::ZERO, + &mut gas_counter, + 0, + ) + .expect_err("storage load failure must not succeed"); + assert_converts_to_fatal(err); + }); + } + + #[test] + fn write_account_load_error_is_fatal() { + with_failing_internals(test_utils::FailingDbMode::AccountLoad, |internals| { + let mut gas_counter = Gas::new(100_000); + let err = write( + internals, + address!("dead000000000000000000000000000000000001"), + StorageKey::ZERO, + &U256::from(1).to_be_bytes_vec(), + &mut gas_counter, + 0, + ) + .expect_err("account load failure must not succeed"); + assert_converts_to_fatal(err); + }); + } + + #[test] + fn write_storage_error_is_fatal() { + with_failing_internals(test_utils::FailingDbMode::Storage, |internals| { + let mut gas_counter = Gas::new(100_000); + let err = write( + internals, + address!("dead000000000000000000000000000000000001"), + StorageKey::ZERO, + &U256::from(1).to_be_bytes_vec(), + &mut gas_counter, + 0, + ) + .expect_err("storage load failure must not succeed"); + assert_converts_to_fatal(err); + }); + } + + #[test] + fn transfer_account_load_error_is_fatal() { + with_failing_internals(test_utils::FailingDbMode::AccountLoad, |internals| { + let mut gas_counter = Gas::new(100_000); + let err = transfer( + internals, + address!("dead000000000000000000000000000000000001"), + address!("dead000000000000000000000000000000000002"), + U256::from(1), + &mut gas_counter, + 0, + ArcHardforkFlags::default(), + ) + .expect_err("account load failure must not succeed"); + assert_converts_to_fatal(err); + }); + } + + #[test] + fn balance_incr_account_load_error_is_fatal() { + with_failing_internals(test_utils::FailingDbMode::AccountLoad, |internals| { + let mut gas_counter = Gas::new(100_000); + let err = balance_incr( + internals, + address!("dead000000000000000000000000000000000001"), + U256::from(1), + &mut gas_counter, + 0, + ) + .expect_err("account load failure must not succeed"); + assert_converts_to_fatal(err); + }); + } + + #[test] + fn balance_decr_account_load_error_is_fatal() { + with_failing_internals(test_utils::FailingDbMode::AccountLoad, |internals| { + let mut gas_counter = Gas::new(100_000); + let err = balance_decr( + internals, + address!("dead000000000000000000000000000000000001"), + U256::from(1), + &mut gas_counter, + 0, + ArcHardforkFlags::default(), + ) + .expect_err("account load failure must not succeed"); + assert_converts_to_fatal(err); + }); + } + + #[test] + fn new_reverted_with_early_penalty_charges_penalty() { + let result = new_reverted_with_early_penalty(Gas::new(10_000), 0, "err"); + let output: Result = result.into(); + let output = output.expect("early penalty returns Revert"); + + assert_eq!(output.gas_used, PRECOMPILE_EARLY_REVERT_GAS_PENALTY); + assert!(output.is_revert()); + } + + #[test] + fn new_reverted_with_early_penalty_oog_returns_halt() { + let result = new_reverted_with_early_penalty( + Gas::new(PRECOMPILE_EARLY_REVERT_GAS_PENALTY - 1), + 0, + "err", + ); + let output: Result = result.into(); + let output = output.expect("OOG returns halt output under revm 38"); + + assert!(output.is_halt()); + assert_eq!(output.halt_reason(), Some(&PrecompileHalt::OutOfGas)); + } + + #[test] + fn precompile_early_revert_gas_penalty_is_200() { + // Zero6 consensus rule: early-path reverts on stateful precompiles charge a uniform + // 200-gas penalty. Pin the literal so a silent change is caught. + assert_eq!(PRECOMPILE_EARLY_REVERT_GAS_PENALTY, 200); } } diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 9ffa5b12..baedf005 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -55,7 +55,6 @@ //! ADDRESS, //! COUNTER_STORAGE_KEY, //! &mut gas_counter, -//! hardfork_flags, //! )?; //! let new_value = U256::from_be_slice(&output) + U256::from(1); //! @@ -65,7 +64,6 @@ //! COUNTER_STORAGE_KEY, //! &new_value.to_be_bytes_vec(), //! &mut gas_counter, -//! hardfork_flags, //! )?; //! //! Ok(PrecompileOutput::new(gas_counter.used(), new_value.abi_encode().into())) @@ -108,10 +106,7 @@ //! let mut gas_counter = Gas::new(precompile_input.gas); //! let mut precompile_input = precompile_input; //! -//! let args = abi_decode_raw_with_zero6_validation::( -//! input, -//! hardfork_flags, -//! ) +//! let args = abi_decode_raw_validated::(input) //! .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( //! gas_counter, //! PRECOMPILE_EARLY_REVERT_GAS_PENALTY, @@ -123,7 +118,6 @@ //! MY_PRECOMPILE_ADDRESS, //! StorageKey::from(0), //! &mut gas_counter, -//! hardfork_flags, //! )?; //! //! Ok(PrecompileOutput::new(gas_counter.used(), output)) @@ -170,7 +164,6 @@ //! address, //! key, //! &mut gas_counter, -//! hardfork_flags, //! )?; //! let current = U256::from_be_slice(&output); //! @@ -180,14 +173,11 @@ //! key, //! &new_value.to_be_bytes_vec(), //! &mut gas_counter, -//! hardfork_flags, //! )?; //! ``` //! -//! Gas costs: -//! - `read`: 2,100 gas pre-Zero5; EIP-2929 warm/cold pricing from Zero5+ -//! (`WARM_STORAGE_READ_COST` / `COLD_SLOAD_COST`). -//! - `write`: 2,900 gas pre-Zero5; EIP-2929 / EIP-2200 pricing from Zero5+. +//! Gas costs use EIP-2929 warm/cold pricing for reads +//! (`WARM_STORAGE_READ_COST` / `COLD_SLOAD_COST`) and EIP-2200 pricing for writes. pub mod helpers; mod macros; diff --git a/crates/precompiles/src/macros.rs b/crates/precompiles/src/macros.rs index 1d5c29d2..0d5f7d45 100644 --- a/crates/precompiles/src/macros.rs +++ b/crates/precompiles/src/macros.rs @@ -30,10 +30,12 @@ /// /// The generated function takes `(PrecompileInput, ArcHardforkFlags)` and returns /// `Result`. Arms that return -/// `PrecompileErrorOrRevert::Revert(...)` are converted into an `Ok(PrecompileOutput)` -/// carrying the revert payload; `PrecompileErrorOrRevert::Error(...)` becomes `Err`. -/// If the calldata is shorter than 4 bytes or the selector is unknown, the macro -/// charges `PRECOMPILE_EARLY_REVERT_GAS_PENALTY` and returns a revert. +/// `PrecompileErrorOrRevert::Revert(...)` or `PrecompileErrorOrRevert::Error(...)` +/// are converted into an `Ok(PrecompileOutput)` carrying the revert or halt +/// payload; `PrecompileErrorOrRevert::Fatal(...)` becomes +/// `Err(PrecompileError::Fatal)`. If the calldata is shorter than 4 bytes or +/// the selector is unknown, the macro charges +/// `PRECOMPILE_EARLY_REVERT_GAS_PENALTY` and returns a revert. /// /// # Example /// ```rust,ignore @@ -48,7 +50,6 @@ /// ADDRESS, /// KEY, /// &mut gas_counter, -/// hardfork_flags, /// )?; /// let new_value = U256::from_be_slice(&output) + U256::from(1); /// @@ -58,7 +59,6 @@ /// KEY, /// &new_value.to_be_bytes_vec(), /// &mut gas_counter, -/// hardfork_flags, /// )?; /// /// Ok(PrecompileOutput::new(gas_counter.used(), new_value.abi_encode().into())) @@ -74,7 +74,6 @@ /// ADDRESS, /// KEY, /// &mut gas_counter, -/// hardfork_flags, /// )?; /// /// Ok(PrecompileOutput::new(gas_counter.used(), output)) @@ -89,7 +88,7 @@ /// - Conversion of `PrecompileErrorOrRevert` into the final `Result` /// /// ABI decoding, gas accounting, and output encoding remain the arm body's job; use -/// `helpers::abi_decode_raw_with_zero6_validation` on the supplied calldata bytes when +/// `helpers::abi_decode_raw_validated` on the supplied calldata bytes when /// you need the decoded arguments. #[macro_export] macro_rules! precompile { @@ -103,11 +102,12 @@ macro_rules! precompile { $hardfork_flags: arc_execution_config::hardforks::ArcHardforkFlags, ) -> Result { let input_bytes = $precompile_input.data; + let reservoir = $precompile_input.reservoir; let gas_counter = revm_interpreter::Gas::new($precompile_input.gas); if input_bytes.len() < 4 { return $crate::helpers::PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, "Input too short").into(); + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, "Input too short").into(); } let selector: [u8; 4] = input_bytes[0..4].try_into().unwrap(); @@ -121,7 +121,7 @@ macro_rules! precompile { ),* _ => { return $crate::helpers::PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, "Invalid selector").into(); + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, "Invalid selector").into(); }, }; diff --git a/crates/precompiles/src/native_coin_authority.rs b/crates/precompiles/src/native_coin_authority.rs index d1749d67..b960ec49 100644 --- a/crates/precompiles/src/native_coin_authority.rs +++ b/crates/precompiles/src/native_coin_authority.rs @@ -20,11 +20,10 @@ //! transfer, and total supply management. use crate::helpers::{ - abi_decode_raw_with_zero6_validation, balance_decr, balance_incr, check_delegatecall, - check_gas_remaining, check_staticcall, emit_event, new_reverted_with_early_penalty, read, - transfer, write, PrecompileErrorOrRevert, ERR_BLOCKED_ADDRESS, ERR_EXECUTION_REVERTED, - LOG_BASE_COST, LOG_DATA_COST, LOG_TOPIC_COST, NATIVE_FIAT_TOKEN_ADDRESS, - PRECOMPILE_EARLY_REVERT_GAS_PENALTY, PRECOMPILE_SLOAD_GAS_COST, PRECOMPILE_SSTORE_GAS_COST, + abi_decode_raw_validated, balance_decr, balance_incr, check_delegatecall, check_gas_remaining, + check_staticcall, emit_event, new_reverted_with_early_penalty, read, transfer, write, + PrecompileErrorOrRevert, ERR_BLOCKED_ADDRESS, ERR_EXECUTION_REVERTED, + NATIVE_FIAT_TOKEN_ADDRESS, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, PRECOMPILE_SLOAD_GAS_COST, }; use crate::native_coin_control::{compute_is_blocklisted_storage_slot, UNBLOCKLISTED_STATUS}; use crate::precompile; @@ -32,7 +31,6 @@ use crate::NATIVE_COIN_CONTROL_ADDRESS; use alloy_evm::EvmInternals; use alloy_primitives::{address, Address, StorageKey, U256}; use alloy_sol_types::{sol, SolCall, SolValue}; -use arc_execution_config::hardforks::{ArcHardfork, ArcHardforkFlags}; use reth_ethereum::evm::revm::precompile::PrecompileOutput; use revm_interpreter::Gas; @@ -45,75 +43,11 @@ use revm::handler::SYSTEM_ADDRESS; // Allowed caller from NativeFiatToken const ALLOWED_CALLER_ADDRESS: Address = NATIVE_FIAT_TOKEN_ADDRESS; -// Storage key for allowed caller (deprecated since Zero5) -const ALLOWED_CALLER_STORAGE_KEY: StorageKey = StorageKey::new([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -]); - // Storage key for total supply const TOTAL_SUPPLY_STORAGE_KEY: StorageKey = StorageKey::new([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, ]); -const MINT_EVENT_GAS_COST: u64 = LOG_BASE_COST + 2 * LOG_TOPIC_COST + 32 * LOG_DATA_COST; // 2 topics + 32 bytes of data -const BURN_EVENT_GAS_COST: u64 = LOG_BASE_COST + 2 * LOG_TOPIC_COST + 32 * LOG_DATA_COST; // 2 topics + 32 bytes of data -const TRANSFER_EVENT_GAS_COST: u64 = LOG_BASE_COST + 3 * LOG_TOPIC_COST + 32 * LOG_DATA_COST; // 3 topics + 32 bytes of data - -// Total gas costs for each operation - -// Mint (pre-Zero5): NativeCoinMinted event (2 topics) -// - Reading allowed caller (2100 gas) -// - Reading blocked to (2100 gas) -// - Reading total supply (2100 gas) -// - Reading account balance (2100 gas) -// - Writing total supply (2900 gas) -// - Writing account balance (2900 gas) -// - Emitting NativeCoinMinted event (1381 gas) -// Total: 15,581 gas -const MINT_GAS_COST: u64 = - 4 * PRECOMPILE_SLOAD_GAS_COST + 2 * PRECOMPILE_SSTORE_GAS_COST + MINT_EVENT_GAS_COST; - -// Mint (Zero5+): upper-bound gas limit for tests. -// Zero5 uses warm/cold SLOAD pricing (100/2100) and removes the auth SLOAD, so -// the actual cost varies per call. This assumes all-cold as a safe upper bound. -// Only used as a test gas limit, not for production gas checks. -#[cfg(test)] -const MINT_GAS_COST_EIP7708: u64 = - 4 * PRECOMPILE_SLOAD_GAS_COST + 2 * PRECOMPILE_SSTORE_GAS_COST + TRANSFER_EVENT_GAS_COST; - -// Burn (pre-Zero5): NativeCoinBurned event (2 topics) -// - Reading allowed caller (2100 gas) -// - Reading blocked from (2100 gas) -// - Reading account balance (2100 gas) -// - Reading total supply (2100 gas) -// - Writing account balance (2900 gas) -// - Writing total supply (2900 gas) -// - Emitting NativeCoinBurned event (1381 gas) -// Total: 15,581 gas -const BURN_GAS_COST: u64 = - 4 * PRECOMPILE_SLOAD_GAS_COST + 2 * PRECOMPILE_SSTORE_GAS_COST + BURN_EVENT_GAS_COST; - -// Burn (Zero5+): upper-bound gas limit for tests. -// Same rationale as MINT_GAS_COST_EIP7708 above. -#[cfg(test)] -const BURN_GAS_COST_EIP7708: u64 = - 4 * PRECOMPILE_SLOAD_GAS_COST + 2 * PRECOMPILE_SSTORE_GAS_COST + TRANSFER_EVENT_GAS_COST; - -// - Reading allowed caller (2100 gas) (removed since Zero5) -// - Reading blocked from (2100 gas) -// - Reading blocked to (2100 gas) -// - Reading account balance x2 (4200 gas) -// - Writing account balance x2 (5800 gas) -// - Emitting event (1756 gas) -// Total: 18056 gas -const TRANSFER_GAS_COST: u64 = - 5 * PRECOMPILE_SLOAD_GAS_COST + 2 * PRECOMPILE_SSTORE_GAS_COST + TRANSFER_EVENT_GAS_COST; -// The gas cost for a transfer operation when the amount is zero -// - Reading allowed caller (2100 gas) (removed since Zero5) -// - Reading blocked from (2100 gas) -// - Reading blocked to (2100 gas) -const TRANSFER_GAS_COST_WITH_ZERO_AMOUNT: u64 = 3 * PRECOMPILE_SLOAD_GAS_COST; - const TOTAL_SUPPLY_GAS_COST: u64 = PRECOMPILE_SLOAD_GAS_COST; // Error messages @@ -140,48 +74,16 @@ sol! { function totalSupply() external view returns (uint256 supply); } - /// Events - #[derive(Debug)] - event NativeCoinMinted(address indexed recipient, uint256 amount); - - #[derive(Debug)] - event NativeCoinBurned(address indexed from, uint256 amount); - - #[derive(Debug)] - event NativeCoinTransferred(address indexed from, address indexed to, uint256 amount); - - /// ERC-20 Transfer event (EIP-7708), used under Zero5 for native coin transfers + /// ERC-20 Transfer event (EIP-7708), used for native coin transfers. #[derive(Debug)] event Transfer(address indexed from, address indexed to, uint256 value); } -/// Checks if the caller is authorized to call mutative native coin authority functions -fn is_authorized( - internals: &mut EvmInternals, - caller: Address, - gas_counter: &mut Gas, - hardfork_flags: ArcHardforkFlags, -) -> Result { - // Get allowed caller - let allowed_caller_output = read( - internals, - NATIVE_COIN_AUTHORITY_ADDRESS, - ALLOWED_CALLER_STORAGE_KEY, - gas_counter, - hardfork_flags, - )?; - - // Compare caller to allowed_caller_output - let caller_word = U256::from_be_slice(caller.as_ref()); - let allowed_caller_word = U256::from_be_slice(&allowed_caller_output); - Ok(caller_word == allowed_caller_word) -} - fn is_blocklisted( internals: &mut EvmInternals, address: Address, gas_counter: &mut Gas, - hardfork_flags: ArcHardforkFlags, + reservoir: u64, ) -> Result { // Get address storage slot for blocklist let storage_slot = compute_is_blocklisted_storage_slot(address); @@ -190,7 +92,7 @@ fn is_blocklisted( NATIVE_COIN_CONTROL_ADDRESS, storage_slot, gas_counter, - hardfork_flags, + reservoir, )?; Ok(!U256::from_be_slice(&storage_output).eq(&UNBLOCKLISTED_STATUS)) @@ -200,6 +102,7 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { INativeCoinAuthority::mintCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); + let reservoir = precompile_input.reservoir; let mut precompile_input = precompile_input; // Check if static call is attempting to modify state check_staticcall( @@ -208,38 +111,18 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { )?; // Decode arguments passed to mint function - let args = abi_decode_raw_with_zero6_validation::( - input, - hardfork_flags, - ) + let args = abi_decode_raw_validated::(input) .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED) + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED) )?; - if hardfork_flags.is_active(ArcHardfork::Zero5) { - // Zero5+: Skip early gas check - warm/cold pricing makes upfront calculation unreliable - // Check authorization - if precompile_input.caller != ALLOWED_CALLER_ADDRESS { - return Err(new_reverted_with_early_penalty( - gas_counter, - ERR_CANNOT_MINT, - hardfork_flags, - )); - } - } else { - // Early return if not enough gas - check_gas_remaining(&gas_counter, MINT_GAS_COST)?; - - // Check authorization - if !is_authorized( - &mut precompile_input.internals, - precompile_input.caller, - &mut gas_counter, - hardfork_flags, - )? { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_CANNOT_MINT, hardfork_flags)); - } + if precompile_input.caller != ALLOWED_CALLER_ADDRESS { + return Err(new_reverted_with_early_penalty( + gas_counter, + reservoir, + ERR_CANNOT_MINT, + )); } // Prevent delegate calls @@ -247,21 +130,21 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; - // Reject minting to zero address (Zero5+) - if hardfork_flags.is_active(ArcHardfork::Zero5) && args.to == Address::ZERO { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_ZERO_ADDRESS, hardfork_flags)); + if args.to == Address::ZERO { + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_ZERO_ADDRESS)); } // Check blocklist - if is_blocklisted(&mut precompile_input.internals, args.to, &mut gas_counter, hardfork_flags)? { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_BLOCKED_ADDRESS, hardfork_flags)); + if is_blocklisted(&mut precompile_input.internals, args.to, &mut gas_counter, reservoir)? { + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_BLOCKED_ADDRESS)); } // Validate amount if args.amount == U256::ZERO { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_ZERO_AMOUNT, hardfork_flags)); + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_ZERO_AMOUNT)); } // Read current total supply @@ -270,14 +153,14 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, TOTAL_SUPPLY_STORAGE_KEY, &mut gas_counter, - hardfork_flags, + reservoir, )?; let current_total_supply = U256::from_be_slice(&total_supply_output); // Check for overflow let new_total_supply = match current_total_supply.checked_add(args.amount) { Some(new_total_supply) => new_total_supply, - None => return Err(new_reverted_with_early_penalty(gas_counter, ERR_OVERFLOW, hardfork_flags)), + None => return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_OVERFLOW)), }; // Write new total supply @@ -287,37 +170,30 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { TOTAL_SUPPLY_STORAGE_KEY, &new_total_supply.to_be_bytes_vec(), &mut gas_counter, - hardfork_flags, + reservoir, )?; // Update account balance - balance_incr(&mut precompile_input.internals, args.to, args.amount, &mut gas_counter, hardfork_flags)?; + balance_incr(&mut precompile_input.internals, args.to, args.amount, &mut gas_counter, reservoir)?; - // Emit event: ERC-20 Transfer(0x0, to) under Zero5, NativeCoinMinted otherwise. // Address::ZERO as `from` follows the ERC-20 convention for minting. This is // intentionally allowed here even though CALL/CREATE value transfers reject // Address::ZERO (see check_blocklist_and_create_log in evm.rs). - if hardfork_flags.is_active(ArcHardfork::Zero5) { - emit_event(&mut precompile_input.internals, SYSTEM_ADDRESS, &Transfer { - from: Address::ZERO, - to: args.to, - value: args.amount, - }, &mut gas_counter)?; - } else { - emit_event(&mut precompile_input.internals, NATIVE_COIN_AUTHORITY_ADDRESS, &NativeCoinMinted { - recipient: args.to, - amount: args.amount, - }, &mut gas_counter)?; - } + emit_event(&mut precompile_input.internals, SYSTEM_ADDRESS, &Transfer { + from: Address::ZERO, + to: args.to, + value: args.amount, + }, &mut gas_counter, reservoir)?; let output = true.abi_encode(); - Ok(PrecompileOutput::new(gas_counter.used(), output.into())) + Ok(PrecompileOutput::new(gas_counter.used(), output.into(), reservoir)) })() }, INativeCoinAuthority::burnCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); + let reservoir = precompile_input.reservoir; let mut precompile_input = precompile_input; // Check if static call is attempting to modify state check_staticcall( @@ -326,33 +202,19 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { )?; // Decode arguments passed to burn function - let args = abi_decode_raw_with_zero6_validation::( - input, - hardfork_flags, - ) + let args = abi_decode_raw_validated::(input) .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, ) )?; - // Check authorization - if hardfork_flags.is_active(ArcHardfork::Zero5) { - // Zero5+: Skip early gas check - warm/cold pricing makes upfront calculation unreliable - if precompile_input.caller != ALLOWED_CALLER_ADDRESS { - return Err(new_reverted_with_early_penalty( - gas_counter, - ERR_CANNOT_BURN, - hardfork_flags, - )); - } - } else { - // Early return if not enough gas - check_gas_remaining(&gas_counter, BURN_GAS_COST)?; - - if !(is_authorized(&mut precompile_input.internals, precompile_input.caller, &mut gas_counter, hardfork_flags)?) { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_CANNOT_BURN, hardfork_flags)); - } + if precompile_input.caller != ALLOWED_CALLER_ADDRESS { + return Err(new_reverted_with_early_penalty( + gas_counter, + reservoir, + ERR_CANNOT_BURN, + )); } // Prevent delegate calls @@ -360,25 +222,25 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; - // Reject burning from zero address (Zero5+) - if hardfork_flags.is_active(ArcHardfork::Zero5) && args.from == Address::ZERO { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_ZERO_ADDRESS, hardfork_flags)); + if args.from == Address::ZERO { + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_ZERO_ADDRESS)); } // Check blocklist - if is_blocklisted(&mut precompile_input.internals, args.from, &mut gas_counter, hardfork_flags)? { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_BLOCKED_ADDRESS, hardfork_flags)); + if is_blocklisted(&mut precompile_input.internals, args.from, &mut gas_counter, reservoir)? { + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_BLOCKED_ADDRESS)); } // Validate amount if args.amount == U256::ZERO { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_ZERO_AMOUNT, hardfork_flags)); + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_ZERO_AMOUNT)); } // Check balance and burn tokens - balance_decr(&mut precompile_input.internals, args.from, args.amount, &mut gas_counter, hardfork_flags)?; + balance_decr(&mut precompile_input.internals, args.from, args.amount, &mut gas_counter, reservoir, hardfork_flags)?; // Adjust total supply let total_supply_output = read( @@ -386,7 +248,7 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, TOTAL_SUPPLY_STORAGE_KEY, &mut gas_counter, - hardfork_flags, + reservoir, )?; let current_total_supply = U256::from_be_slice(&total_supply_output); @@ -398,35 +260,28 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { TOTAL_SUPPLY_STORAGE_KEY, ¤t_total_supply.saturating_sub(args.amount).to_be_bytes_vec(), &mut gas_counter, - hardfork_flags, + reservoir, )?; - // Emit event: ERC-20 Transfer(from, 0x0) under Zero5, NativeCoinBurned otherwise. // Address::ZERO as `to` follows the ERC-20 convention for burning. This is // intentionally allowed here even though CALL/CREATE value transfers reject // Address::ZERO (see check_blocklist_and_create_log in evm.rs). - if hardfork_flags.is_active(ArcHardfork::Zero5) { - emit_event(&mut precompile_input.internals, SYSTEM_ADDRESS, &Transfer { - from: args.from, - to: Address::ZERO, - value: args.amount, - }, &mut gas_counter)?; - } else { - emit_event(&mut precompile_input.internals, NATIVE_COIN_AUTHORITY_ADDRESS, &NativeCoinBurned { - from: args.from, - amount: args.amount, - }, &mut gas_counter)?; - } + emit_event(&mut precompile_input.internals, SYSTEM_ADDRESS, &Transfer { + from: args.from, + to: Address::ZERO, + value: args.amount, + }, &mut gas_counter, reservoir)?; // Return response let output = true.abi_encode(); - Ok(PrecompileOutput::new(gas_counter.used(), output.into())) + Ok(PrecompileOutput::new(gas_counter.used(), output.into(), reservoir)) })() }, INativeCoinAuthority::transferCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); + let reservoir = precompile_input.reservoir; let mut precompile_input = precompile_input; // Check if static call is attempting to modify state check_staticcall( @@ -435,40 +290,19 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { )?; // Decode arguments passed to transfer function - let args = abi_decode_raw_with_zero6_validation::( - input, - hardfork_flags, - ) + let args = abi_decode_raw_validated::(input) .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, ) )?; - // Check authorization - if hardfork_flags.is_active(ArcHardfork::Zero5) { - // Zero5+: Skip early gas check - warm/cold pricing makes upfront calculation unreliable - if precompile_input.caller != ALLOWED_CALLER_ADDRESS { - return Err(new_reverted_with_early_penalty( - gas_counter, - ERR_CANNOT_TRANSFER, - hardfork_flags, - )); - } - } else { - // The gas cost is different if the amount is zero or not - let expect_gas_cost = if args.amount != U256::ZERO { - TRANSFER_GAS_COST - } else { - TRANSFER_GAS_COST_WITH_ZERO_AMOUNT - }; - - // Early return if not enough gas - check_gas_remaining(&gas_counter, expect_gas_cost)?; - - if !(is_authorized(&mut precompile_input.internals, precompile_input.caller, &mut gas_counter, hardfork_flags)?) { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_CANNOT_TRANSFER, hardfork_flags)); - } + if precompile_input.caller != ALLOWED_CALLER_ADDRESS { + return Err(new_reverted_with_early_penalty( + gas_counter, + reservoir, + ERR_CANNOT_TRANSFER, + )); } // Prevent delegate calls @@ -476,69 +310,57 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; - // Reject transfers involving zero address (Zero5+) - if hardfork_flags.is_active(ArcHardfork::Zero5) - && (args.from == Address::ZERO || args.to == Address::ZERO) - { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_ZERO_ADDRESS, hardfork_flags)); + if args.from == Address::ZERO || args.to == Address::ZERO { + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_ZERO_ADDRESS)); } // Check blocklist - if is_blocklisted(&mut precompile_input.internals, args.from, &mut gas_counter, hardfork_flags)? { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_BLOCKED_ADDRESS, hardfork_flags)); + if is_blocklisted(&mut precompile_input.internals, args.from, &mut gas_counter, reservoir)? { + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_BLOCKED_ADDRESS)); } - if is_blocklisted(&mut precompile_input.internals, args.to, &mut gas_counter, hardfork_flags)? { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_BLOCKED_ADDRESS, hardfork_flags)); + if is_blocklisted(&mut precompile_input.internals, args.to, &mut gas_counter, reservoir)? { + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_BLOCKED_ADDRESS)); } // Zero amount transfers are allowed, but do not emit an event if args.amount != U256::ZERO { // Note on self-transfers (from == to): REVM's transfer_loaded() early-returns // without touching balances for self-transfers. Here we still call transfer() - // which performs balance_decr + balance_incr (net zero change). This is - // functionally correct and intentionally kept to preserve identical gas costs - // across the Zero5 hardfork boundary — skipping the balance ops would reduce - // gas consumption and break the "gas cost unchanged" invariant. - transfer(&mut precompile_input.internals, args.from, args.to, args.amount, &mut gas_counter, hardfork_flags)?; + // which performs balance_decr + balance_incr (net zero change). + transfer(&mut precompile_input.internals, args.from, args.to, args.amount, &mut gas_counter, reservoir, hardfork_flags)?; - // Emit event: ERC-20 Transfer under Zero5, NativeCoinTransferred otherwise. // EIP-7708: self-transfers (from == to) do not emit a log. - if hardfork_flags.is_active(ArcHardfork::Zero5) { - if args.from != args.to { - emit_event(&mut precompile_input.internals, SYSTEM_ADDRESS, &Transfer { - from: args.from, - to: args.to, - value: args.amount, - }, &mut gas_counter)?; - } - } else { - emit_event(&mut precompile_input.internals, NATIVE_COIN_AUTHORITY_ADDRESS, &NativeCoinTransferred { + if args.from != args.to { + emit_event(&mut precompile_input.internals, SYSTEM_ADDRESS, &Transfer { from: args.from, to: args.to, - amount: args.amount, - }, &mut gas_counter)?; + value: args.amount, + }, &mut gas_counter, reservoir)?; } } // Return response let output = true.abi_encode(); - Ok(PrecompileOutput::new(gas_counter.used(), output.into())) + Ok(PrecompileOutput::new(gas_counter.used(), output.into(), reservoir)) })() }, INativeCoinAuthority::totalSupplyCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); + let reservoir = precompile_input.reservoir; let mut precompile_input = precompile_input; - if !hardfork_flags.is_active(ArcHardfork::Zero6) && !input.is_empty() { - return Err(PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED)); - } + abi_decode_raw_validated::(input) + .map_err(|_| + PrecompileErrorOrRevert::new_reverted_with_penalty( + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED) + )?; // Early return if not enough gas - check_gas_remaining(&gas_counter, TOTAL_SUPPLY_GAS_COST)?; + check_gas_remaining(&gas_counter, reservoir, TOTAL_SUPPLY_GAS_COST)?; // Read the total supply let total_supply_output = read( @@ -546,13 +368,13 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, TOTAL_SUPPLY_STORAGE_KEY, &mut gas_counter, - hardfork_flags, + reservoir, )?; let total_supply = U256::from_be_slice(&total_supply_output); // Return response let output = total_supply.abi_encode(); - Ok(PrecompileOutput::new(gas_counter.used(), output.into())) + Ok(PrecompileOutput::new(gas_counter.used(), output.into(), reservoir)) })() }, }); @@ -562,13 +384,14 @@ mod tests { use super::*; use crate::helpers::{ ERR_CLEAR_EMPTY, ERR_DELEGATE_CALL_NOT_ALLOWED, ERR_INSUFFICIENT_FUNDS, - ERR_SELFDESTRUCTED_BALANCE_INCREASED, REVERT_SELECTOR, + ERR_SELFDESTRUCTED_BALANCE_INCREASED, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + PRECOMPILE_SSTORE_GAS_COST, REVERT_SELECTOR, }; use crate::native_coin_control::{ compute_is_blocklisted_storage_slot, run_native_coin_control, BLOCKLISTED_STATUS, NATIVE_COIN_CONTROL_ADDRESS, UNBLOCKLISTED_STATUS, }; - use alloy_primitives::Bytes; + use alloy_primitives::{Bytes, B256}; use alloy_sol_types::SolEvent; use arc_execution_config::hardforks::{ArcHardfork, ArcHardforkFlags}; use reth_ethereum::evm::revm::{ @@ -586,7 +409,7 @@ mod tests { use revm_context_interface::journaled_state::account::JournaledAccountTr; use std::collections::HashSet; - fn mock_context(hardfork_flags: ArcHardforkFlags) -> revm::Context { + fn mock_context(_hardfork_flags: ArcHardforkFlags) -> revm::Context { let mut ctx = Context::mainnet(); // Set up native coin authority @@ -594,16 +417,6 @@ mod tests { .load_account(NATIVE_COIN_AUTHORITY_ADDRESS) .expect("Unable to load native coin authority account"); - if !hardfork_flags.is_active(ArcHardfork::Zero5) { - ctx.journal_mut() - .sstore( - NATIVE_COIN_AUTHORITY_ADDRESS, - ALLOWED_CALLER_STORAGE_KEY.into(), - U256::from_be_slice(ALLOWED_CALLER_ADDRESS.as_ref()), - ) - .expect("Unable to write allowed caller"); - } - // Preload native coin control account, it will load storage slot in tests. ctx.journal_mut() .load_account(NATIVE_COIN_CONTROL_ADDRESS) @@ -612,11 +425,20 @@ mod tests { ctx } - fn call_native_coin_authority( - ctx: &mut Context, + fn call_native_coin_authority( + ctx: &mut revm::context::Context< + revm::context::BlockEnv, + revm::context::TxEnv, + revm::context::CfgEnv, + DB, + revm::context::Journal, + >, inputs: &CallInputs, hardfork_flags: ArcHardforkFlags, - ) -> Result, String> { + ) -> Result, String> + where + DB: revm::database_interface::Database + std::fmt::Debug, + { // The EvmInternals has no public constructor, so we can not test DynPrecompile directly. let mut provider = PrecompilesMap::from_static(Precompiles::latest()); let target_addr: Address = inputs.target_address; @@ -644,58 +466,13 @@ mod tests { caller: Address, calldata: Bytes, gas_limit: u64, - /// If set, overrides gas_limit for pre-Zero5 hardforks - pre_zero5_gas_limit: Option, - /// If set, overrides gas_limit when EIP-7708 (Zero5) is active (different event costs) - eip7708_gas_limit: Option, - /// If set, overrides gas_limit when Zero5 and Zero6 are both active. - /// Needed when Zero6's warm-account discount shifts the OOG boundary. - zero6_gas_limit: Option, expected_revert_str: Option<&'static str>, expected_result: InstructionResult, return_data: Option, blocklisted_addresses: Option>, gas_used: u64, - /// If set, overrides gas_used for pre-Zero5 hardforks (before warm/cold aware storage costs) - pre_zero5_gas_used: Option, - /// If set, overrides gas_used when EIP-7708 (Zero5) is active. - eip7708_gas_used: Option, - /// If set, overrides gas_used when Zero5 and Zero6 are both active. - zero6_gas_used: Option, target_address: Address, bytecode_address: Address, - /// If true, skip this test case for hardfork combinations without Zero5 (EIP-7708). - /// Defaults to false when not specified (via `..Default::default()`). - eip7708_only: bool, - /// If true, skip this test case for hardfork combinations with Zero5 (EIP-7708). - /// Used for OOG tests whose gas boundary changes when EIP-7708 suppresses event emission. - pre_eip7708_only: bool, - } - - impl Default for NativeCoinAuthorityTest { - fn default() -> Self { - Self { - name: "", - caller: Address::ZERO, - calldata: Bytes::new(), - gas_limit: 0, - pre_zero5_gas_limit: None, - eip7708_gas_limit: None, - zero6_gas_limit: None, - expected_revert_str: None, - expected_result: InstructionResult::Stop, - return_data: None, - blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: None, - eip7708_gas_used: None, - zero6_gas_used: None, - target_address: Address::ZERO, - bytecode_address: Address::ZERO, - eip7708_only: false, - pre_eip7708_only: false, - } - } } // Test constants @@ -703,12 +480,22 @@ mod tests { const ADDRESS_B: Address = address!("2000000000000000000000000000000000000002"); const ADDRESS_C: Address = address!("300000D000000000000000000000000000000003"); const NON_EMPTY_ADDRESS: Address = address!("400000D000000000000000000000000000000004"); + const TEST_GAS_LIMIT: u64 = 100_000; const ZERO6_EMPTY_ACCOUNT_GAS_DELTA: u64 = crate::helpers::PRECOMPILE_EMPTY_ACCOUNT_GAS_COST; + fn baseline_flags() -> ArcHardforkFlags { + ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ]) + } + fn assert_precompile_result( precompile_res: Result, String>, tc: &NativeCoinAuthorityTest, - hardfork_flags: ArcHardforkFlags, + _hardfork_flags: ArcHardforkFlags, tc_name: &str, ) { match precompile_res { @@ -747,24 +534,16 @@ mod tests { ); } - // Resolve expected gas per hardfork combination. Zero5 changes auth / event - // shape (EIP-7708). Zero6 changes account-load pricing inside the balance - // helpers. Zero6 is cumulative (implies Zero5), so the {!Zero5, Zero6} cell - // is filtered out by the test loop. - let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero6) { - tc.zero6_gas_used - .or(tc.eip7708_gas_used) - .unwrap_or(tc.gas_used) - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - tc.eip7708_gas_used.unwrap_or(tc.gas_used) - } else { - tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) - }; - assert_eq!( - result.gas.used(), - expected_gas_used, - "{tc_name}: gas used to match" - ); + // Skip the gas-used assertion on PrecompileOOG: under revm 38 the + // precompile-result converter always `spend_all()`s on Halt, so + // `result.gas.used()` is tautologically the gas_limit. + if tc.expected_result != InstructionResult::PrecompileOOG { + assert_eq!( + result.gas.used(), + tc.gas_used, + "{tc_name}: gas used to match" + ); + } } Err(e) => { panic!("{tc_name}: unexpected error {:?}", e) @@ -836,19 +615,6 @@ mod tests { .expect("Unable to write initial balance for NON_EMPTY_ADDRESS"); } - /// Returns the appropriate gas limit based on hardfork - fn get_gas_limit(tc: &NativeCoinAuthorityTest, hardfork_flags: ArcHardforkFlags) -> u64 { - if hardfork_flags.is_active(ArcHardfork::Zero6) { - tc.zero6_gas_limit - .or(tc.eip7708_gas_limit) - .unwrap_or(tc.gas_limit) - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - tc.eip7708_gas_limit.unwrap_or(tc.gas_limit) - } else { - tc.pre_zero5_gas_limit.unwrap_or(tc.gas_limit) - } - } - /// Validates test case configuration fn validate_test_case(tc: &NativeCoinAuthorityTest) { match tc.expected_result { @@ -873,7 +639,8 @@ mod tests { let mock_initial_supply = U256::from(1_000_000_000); let cases: &[NativeCoinAuthorityTest] = &[ - // Authorization check is now a constant comparison (no SLOAD), reverts with 0 gas + // Authorization check is now a constant comparison (no SLOAD), then + // charges the early-revert penalty. NativeCoinAuthorityTest { name: "mint() unauthorized caller reverts", caller: ADDRESS_A, @@ -884,17 +651,13 @@ mod tests { .abi_encode() .into(), gas_limit: 100_000, - pre_zero5_gas_limit: None, expected_revert_str: Some(ERR_CANNOT_MINT), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, blocklist SLOAD is cold (2100), reverts before other ops NativeCoinAuthorityTest { @@ -907,17 +670,13 @@ mod tests { .abi_encode() .into(), gas_limit: 100_000, - pre_zero5_gas_limit: None, expected_revert_str: Some(ERR_ZERO_AMOUNT), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 2100, // blocklist check cold SLOAD only - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 2), - zero6_gas_used: Some(2100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: 2100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, // blocklist check cold SLOAD only target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, blocklist cold (2100), total supply warm (100) - warm because test setup writes it NativeCoinAuthorityTest { @@ -930,73 +689,39 @@ mod tests { .abi_encode() .into(), gas_limit: 100_000, - pre_zero5_gas_limit: None, expected_revert_str: Some(ERR_OVERFLOW), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 2200, // blocklist cold (2100) + total_supply warm (100) - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 3), - zero6_gas_used: Some(2200 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY), - target_address: NATIVE_COIN_AUTHORITY_ADDRESS, - bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() - }, - NativeCoinAuthorityTest { - name: "mint() insufficient gas errors with OOG", - caller: ALLOWED_CALLER_ADDRESS, - calldata: INativeCoinAuthority::mintCall { - to: ADDRESS_B, - amount: U256::from(1), - } - .abi_encode() - .into(), - // Pre-Zero5: uses early gas check with MINT_GAS_COST - PRECOMPILE_SLOAD_GAS_COST - 1 - // Zero5 (EIP-7708): uses Transfer event (9056 gas needed), so 8680 still triggers OOG - gas_limit: 8680, - pre_zero5_gas_limit: Some(MINT_GAS_COST - PRECOMPILE_SLOAD_GAS_COST - 1), - expected_revert_str: None, - expected_result: InstructionResult::PrecompileOOG, - return_data: None, - blocklisted_addresses: None, - gas_used: 0, // for OOG, it not the responsibility for the precompile layer - pre_zero5_gas_used: None, + gas_used: 2200 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, // blocklist cold (2100) + total_supply warm (100) target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - pre_eip7708_only: true, - ..Default::default() }, NativeCoinAuthorityTest { name: "mint() invalid params errors with Execution Reverted", caller: ALLOWED_CALLER_ADDRESS, calldata: INativeCoinAuthority::mintCall::SELECTOR.into(), - gas_limit: MINT_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_EXECUTION_REVERTED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, NativeCoinAuthorityTest { name: "mint() prevents calls if target != precompile address", caller: ALLOWED_CALLER_ADDRESS, calldata: INativeCoinAuthority::mintCall::SELECTOR.into(), - gas_limit: MINT_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_EXECUTION_REVERTED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, target_address: ADDRESS_B, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, delegate check happens before any storage ops NativeCoinAuthorityTest { @@ -1008,17 +733,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: MINT_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, // No auth SLOAD in Zero5 - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), // as it comes after the authorization check SLOAD + gas_used: 0, // No auth SLOAD target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: ADDRESS_B, // different bytecode address - ..Default::default() }, // No auth SLOAD, delegate check happens before any storage ops NativeCoinAuthorityTest { @@ -1030,20 +752,16 @@ mod tests { } .abi_encode() .into(), - gas_limit: MINT_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, // No auth SLOAD in Zero5 - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), // as it comes after the authorization check SLOAD - target_address: ADDRESS_B, // different target address + gas_used: 0, // No auth SLOAD + target_address: ADDRESS_B, // different target address bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, - // Zero5: blocklist cold (2100) + total_supply warm read/write (200) - // + balance_incr fixed (5000) + Transfer event (1756) = 9056. + // Empty recipients also pay the baseline empty-account creation surcharge. NativeCoinAuthorityTest { name: "mint() success and returns true", caller: ALLOWED_CALLER_ADDRESS, @@ -1053,23 +771,16 @@ mod tests { } .abi_encode() .into(), - gas_limit: MINT_GAS_COST, - pre_zero5_gas_limit: None, - eip7708_gas_limit: Some(MINT_GAS_COST_EIP7708), - zero6_gas_limit: Some(MINT_GAS_COST_EIP7708 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA), + gas_limit: TEST_GAS_LIMIT, expected_revert_str: None, expected_result: InstructionResult::Return, return_data: Some(true.abi_encode().into()), blocklisted_addresses: None, - gas_used: 8681, - pre_zero5_gas_used: Some(MINT_GAS_COST), - eip7708_gas_used: Some(9056), - zero6_gas_used: Some(9556 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA), + gas_used: 9556 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, - // Zero6: NON_EMPTY_ADDRESS is initialized in test setup, so balance_incr() + // Baseline: NON_EMPTY_ADDRESS is initialized in test setup, so balance_incr() // must not charge the empty-account creation surcharge. NativeCoinAuthorityTest { name: "mint() to non-empty account succeeds without empty account surcharge", @@ -1080,24 +791,18 @@ mod tests { } .abi_encode() .into(), - gas_limit: MINT_GAS_COST, - pre_zero5_gas_limit: None, - eip7708_gas_limit: Some(MINT_GAS_COST_EIP7708), + gas_limit: TEST_GAS_LIMIT, expected_revert_str: None, expected_result: InstructionResult::Return, return_data: Some(true.abi_encode().into()), blocklisted_addresses: None, - gas_used: 8681, - pre_zero5_gas_used: Some(MINT_GAS_COST), - eip7708_gas_used: Some(9056), - zero6_gas_used: Some(7056), + gas_used: 7056, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, zero-address check precedes blocklist SLOADs NativeCoinAuthorityTest { - name: "mint() to zero address reverts (Zero5+)", + name: "mint() to zero address reverts", caller: ALLOWED_CALLER_ADDRESS, calldata: INativeCoinAuthority::mintCall { to: Address::ZERO, @@ -1105,19 +810,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: MINT_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_ZERO_ADDRESS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - eip7708_only: true, - ..Default::default() }, // No auth SLOAD, reverts immediately NativeCoinAuthorityTest { @@ -1129,18 +829,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: BURN_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_CANNOT_BURN), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, blocklist SLOAD cold (2100), reverts before balance ops NativeCoinAuthorityTest { @@ -1152,20 +848,16 @@ mod tests { } .abi_encode() .into(), - gas_limit: BURN_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_ZERO_AMOUNT), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 2100, // blocklist cold SLOAD only - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 2), - zero6_gas_used: Some(2100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: 2100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, // blocklist cold SLOAD only target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, - // No auth SLOAD, blocklist cold (2100), balance check SLOAD (fixed 2100) + // No auth SLOAD, blocklist cold (2100), warm balance check (100) NativeCoinAuthorityTest { name: "burn() more than balance reverts with insufficient funds", caller: ALLOWED_CALLER_ADDRESS, @@ -1175,58 +867,27 @@ mod tests { } .abi_encode() .into(), - gas_limit: BURN_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_INSUFFICIENT_FUNDS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 4200, // blocklist cold + balance check fixed - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 3), - zero6_gas_used: Some(2200), + gas_used: 2200, // blocklist cold + warm balance check target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() - }, - NativeCoinAuthorityTest { - name: "burn() with insufficient gas errors with OOG", - caller: ALLOWED_CALLER_ADDRESS, - calldata: INativeCoinAuthority::burnCall { - from: ADDRESS_A, - amount: U256::from(1), - } - .abi_encode() - .into(), - // Pre-Zero5: uses early gas check with BURN_GAS_COST - PRECOMPILE_SLOAD_GAS_COST - 1 - // Zero5 (EIP-7708): uses Transfer event (9056 gas needed), so 8680 still triggers OOG - gas_limit: 8680, - pre_zero5_gas_limit: Some(BURN_GAS_COST - PRECOMPILE_SLOAD_GAS_COST - 1), - expected_revert_str: None, - expected_result: InstructionResult::PrecompileOOG, - return_data: None, - blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: None, - target_address: NATIVE_COIN_AUTHORITY_ADDRESS, - bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - pre_eip7708_only: true, - ..Default::default() }, NativeCoinAuthorityTest { name: "burn() with invalid params reverts with Execution Reverted", caller: ALLOWED_CALLER_ADDRESS, calldata: INativeCoinAuthority::burnCall::SELECTOR.into(), - gas_limit: BURN_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_EXECUTION_REVERTED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, delegate check happens before storage ops NativeCoinAuthorityTest { @@ -1238,17 +899,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: BURN_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), target_address: ADDRESS_B, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, delegate check happens before storage ops NativeCoinAuthorityTest { @@ -1260,21 +918,16 @@ mod tests { } .abi_encode() .into(), - gas_limit: BURN_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: ADDRESS_B, - ..Default::default() }, - // blocklist cold (2100) + balance_decr fixed (5000) + total_supply warm read (100) - // Zero5: blocklist cold (2100) + balance_decr fixed (5000) - // + total_supply warm read/write (200) + Transfer event (1756) = 9056. + // Baseline burn success uses warm/cold-aware balance and total-supply storage costs. NativeCoinAuthorityTest { name: "burn() succeeds and returns true", caller: ALLOWED_CALLER_ADDRESS, @@ -1284,24 +937,18 @@ mod tests { } .abi_encode() .into(), - gas_limit: BURN_GAS_COST, - pre_zero5_gas_limit: None, - eip7708_gas_limit: Some(BURN_GAS_COST_EIP7708), + gas_limit: TEST_GAS_LIMIT, expected_revert_str: None, expected_result: InstructionResult::Return, return_data: Some(true.abi_encode().into()), blocklisted_addresses: None, - gas_used: 8681, - pre_zero5_gas_used: Some(BURN_GAS_COST), - eip7708_gas_used: Some(9056), - zero6_gas_used: Some(7056), + gas_used: 7056, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, zero-address check precedes blocklist SLOADs NativeCoinAuthorityTest { - name: "burn() from zero address reverts (Zero5+)", + name: "burn() from zero address reverts", caller: ALLOWED_CALLER_ADDRESS, calldata: INativeCoinAuthority::burnCall { from: Address::ZERO, @@ -1309,19 +956,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: BURN_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_ZERO_ADDRESS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - eip7708_only: true, - ..Default::default() }, // No auth SLOAD, reverts immediately NativeCoinAuthorityTest { @@ -1334,20 +976,16 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_CANNOT_TRANSFER), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, - // No auth SLOAD, from blocklist cold (2100), to blocklist cold (2100), balance check fixed (2100) + // No auth SLOAD, from/to blocklist cold (4200), warm balance check (100) NativeCoinAuthorityTest { name: "transfer() more than balance reverts with insufficient funds", caller: ALLOWED_CALLER_ADDRESS, @@ -1358,19 +996,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_INSUFFICIENT_FUNDS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 6300, // 2 blocklist cold SLOADs (4200) + balance check (2100) - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 4), - // Zero6: warm from-account load (100) replaces fixed 2100 - zero6_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 2 + 100), + gas_used: PRECOMPILE_SLOAD_GAS_COST * 2 + 100, // 2 blocklist cold SLOADs + warm balance check target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, NativeCoinAuthorityTest { name: "transfer() with insufficient gas errors with OOG", @@ -1382,37 +1015,29 @@ mod tests { } .abi_encode() .into(), - // Zero5: 15956 gas needed for success, use 15955 to trigger OOG - // Zero5 + Zero6: warm-from discount drops success to 14456, use 14455 - // Pre-Zero5: uses early gas check with TRANSFER_GAS_COST - PRECOMPILE_SLOAD_GAS_COST - 1 - gas_limit: 15955, - pre_zero5_gas_limit: Some(TRANSFER_GAS_COST - PRECOMPILE_SLOAD_GAS_COST - 1), - zero6_gas_limit: Some(14455), + // Baseline: warm-from discount drops success to 14456, use 14455 + // to trigger OOG. + gas_limit: 14455, expected_revert_str: None, expected_result: InstructionResult::PrecompileOOG, return_data: None, blocklisted_addresses: None, gas_used: 0, - pre_zero5_gas_used: None, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, NativeCoinAuthorityTest { name: "transfer() with invalid params reverts with Execution Reverted", caller: ALLOWED_CALLER_ADDRESS, calldata: INativeCoinAuthority::transferCall::SELECTOR.into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_EXECUTION_REVERTED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, delegate check happens before storage ops NativeCoinAuthorityTest { @@ -1425,17 +1050,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), target_address: ADDRESS_B, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, delegate check happens before storage ops NativeCoinAuthorityTest { @@ -1448,21 +1070,18 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: ADDRESS_B, - ..Default::default() }, // No auth SLOAD, zero-address check precedes blocklist SLOADs NativeCoinAuthorityTest { - name: "transfer() to zero address reverts (Zero5+)", + name: "transfer() to zero address reverts", caller: ALLOWED_CALLER_ADDRESS, calldata: INativeCoinAuthority::transferCall { from: ADDRESS_A, @@ -1471,22 +1090,17 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_ZERO_ADDRESS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - eip7708_only: true, - ..Default::default() }, NativeCoinAuthorityTest { - name: "transfer() from zero address reverts (Zero5+)", + name: "transfer() from zero address reverts", caller: ALLOWED_CALLER_ADDRESS, calldata: INativeCoinAuthority::transferCall { from: Address::ZERO, @@ -1495,22 +1109,17 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_ZERO_ADDRESS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - eip7708_only: true, - ..Default::default() }, NativeCoinAuthorityTest { - name: "transfer() with both zero addresses reverts (Zero5+)", + name: "transfer() with both zero addresses reverts", caller: ALLOWED_CALLER_ADDRESS, calldata: INativeCoinAuthority::transferCall { from: Address::ZERO, @@ -1519,22 +1128,16 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_ZERO_ADDRESS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - eip7708_only: true, - ..Default::default() }, - // from blocklist cold (2100) + to blocklist cold (2100) + transfer fixed (10000) - // + event (1756) = 15956 + // Empty recipients also pay the baseline empty-account creation surcharge. NativeCoinAuthorityTest { name: "transfer() with non-zero amount succeeds and returns true", caller: ALLOWED_CALLER_ADDRESS, @@ -1545,21 +1148,16 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, - zero6_gas_limit: Some(TRANSFER_GAS_COST + ZERO6_EMPTY_ACCOUNT_GAS_DELTA), + gas_limit: TEST_GAS_LIMIT, expected_revert_str: None, expected_result: InstructionResult::Return, return_data: Some(true.abi_encode().into()), blocklisted_addresses: None, - gas_used: 15956, - pre_zero5_gas_used: Some(TRANSFER_GAS_COST), - zero6_gas_used: Some(14456 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA), + gas_used: 14456 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, - // Zero6: NON_EMPTY_ADDRESS is initialized in test setup, so transfer() + // Baseline: NON_EMPTY_ADDRESS is initialized in test setup, so transfer() // must not charge the empty-account creation surcharge. NativeCoinAuthorityTest { name: "transfer() to non-empty account succeeds without empty account surcharge", @@ -1571,20 +1169,17 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: None, expected_result: InstructionResult::Return, return_data: Some(true.abi_encode().into()), blocklisted_addresses: None, - gas_used: 15956, - pre_zero5_gas_used: Some(TRANSFER_GAS_COST), - zero6_gas_used: Some(11956), + gas_used: 11956, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, - // No auth SLOAD, from/to blocklist cold (4200), balance check (2100), balance decr SSTORE penalty (2900) + // No auth SLOAD, from/to blocklist cold (4200), warm balance check (100), + // balance-decrease SSTORE reset (2900) NativeCoinAuthorityTest { name: "transfer() full balance for empty account reverts", caller: ALLOWED_CALLER_ADDRESS, @@ -1595,23 +1190,16 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_CLEAR_EMPTY), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: None, - gas_used: 9200, // 2 blocklist cold (4200) + balance (2100) + SSTORE penalty (2900) - pre_zero5_gas_used: Some( - PRECOMPILE_SLOAD_GAS_COST * 4 + PRECOMPILE_SSTORE_GAS_COST, - ), - zero6_gas_used: Some(7200), + gas_used: 7200, // 2 blocklist cold SLOADs + warm balance check + SSTORE reset target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, - // from blocklist cold (2100) + to blocklist cold (2100) + transfer fixed (10000) - // + event (1756) = 15956 + // Empty recipients also pay the baseline empty-account creation surcharge. NativeCoinAuthorityTest { name: "transfer() full balance for non-empty account does not revert", caller: ALLOWED_CALLER_ADDRESS, @@ -1622,19 +1210,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, - zero6_gas_limit: Some(TRANSFER_GAS_COST + ZERO6_EMPTY_ACCOUNT_GAS_DELTA), + gas_limit: TEST_GAS_LIMIT, expected_revert_str: None, expected_result: InstructionResult::Return, return_data: Some(true.abi_encode().into()), blocklisted_addresses: None, - gas_used: 15956, - pre_zero5_gas_used: Some(TRANSFER_GAS_COST), - zero6_gas_used: Some(14456 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA), + gas_used: 14456 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // No auth SLOAD, from/to blocklist cold (4200) - no balance ops for zero amount NativeCoinAuthorityTest { @@ -1647,17 +1230,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: None, expected_result: InstructionResult::Return, return_data: Some(true.abi_encode().into()), blocklisted_addresses: None, gas_used: 4200, // 2 blocklist cold SLOADs - pre_zero5_gas_used: Some(TRANSFER_GAS_COST_WITH_ZERO_AMOUNT), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // total_supply warm SLOAD (100) - test setup writes it NativeCoinAuthorityTest { @@ -1665,32 +1245,26 @@ mod tests { caller: ADDRESS_A, calldata: INativeCoinAuthority::totalSupplyCall::SELECTOR.into(), gas_limit: TOTAL_SUPPLY_GAS_COST, - pre_zero5_gas_limit: None, expected_revert_str: None, expected_result: InstructionResult::Return, return_data: Some(mock_initial_supply.abi_encode().into()), blocklisted_addresses: None, gas_used: 100, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, NativeCoinAuthorityTest { name: "totalSupply() errors with OOG if insufficient gas", caller: ADDRESS_A, calldata: INativeCoinAuthority::totalSupplyCall::SELECTOR.into(), gas_limit: TOTAL_SUPPLY_GAS_COST - 1, // Not enough gas - pre_zero5_gas_limit: None, expected_revert_str: None, expected_result: InstructionResult::PrecompileOOG, return_data: None, blocklisted_addresses: None, gas_used: 0, - pre_zero5_gas_used: None, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // Blocklist test cases // blocklist warm (100) - test setup writes to blocklist slot, making it warm @@ -1703,21 +1277,17 @@ mod tests { } .abi_encode() .into(), - gas_limit: MINT_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_BLOCKED_ADDRESS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: Some(HashSet::from([ADDRESS_B])), - gas_used: 100, // blocklist warm (test setup wrote it) - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 2), - zero6_gas_used: Some(100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: 100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, // blocklist warm (test setup wrote it) target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, - // Zero5: blocklist cold (2100) + total_supply warm read/write (200) - // + balance_incr fixed (5000) + Transfer event (1756) = 9056. + // Blocklist status is cold here; recipient balance and total-supply storage use + // the same baseline warm/cold-aware accounting as the success case above. NativeCoinAuthorityTest { name: "mint() to non-blocklisted address succeeds", caller: ALLOWED_CALLER_ADDRESS, @@ -1727,21 +1297,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: MINT_GAS_COST, - pre_zero5_gas_limit: None, - eip7708_gas_limit: Some(MINT_GAS_COST_EIP7708), - zero6_gas_limit: Some(MINT_GAS_COST_EIP7708 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA), + gas_limit: TEST_GAS_LIMIT, expected_revert_str: None, expected_result: InstructionResult::Return, return_data: Some(true.abi_encode().into()), blocklisted_addresses: Some(HashSet::from([ADDRESS_C])), - gas_used: 8681, - pre_zero5_gas_used: Some(MINT_GAS_COST), - eip7708_gas_used: Some(9056), - zero6_gas_used: Some(9556 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA), + gas_used: 9556 + ZERO6_EMPTY_ACCOUNT_GAS_DELTA, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // blocklist warm (100) - test setup writes to blocklist slot, making it warm NativeCoinAuthorityTest { @@ -1753,18 +1316,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: BURN_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_BLOCKED_ADDRESS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: Some(HashSet::from([ADDRESS_B])), - gas_used: 100, // blocklist warm (test setup wrote it) - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 2), - zero6_gas_used: Some(100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: 100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, // blocklist warm (test setup wrote it) target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // from blocklist warm (100) - test setup writes to blocklist slot NativeCoinAuthorityTest { @@ -1777,18 +1336,14 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_BLOCKED_ADDRESS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: Some(HashSet::from([ADDRESS_A])), - gas_used: 100, // from blocklist warm (test setup wrote it) - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 2), - zero6_gas_used: Some(100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: 100 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, // from blocklist warm (test setup wrote it) target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, // from blocklist cold (2100), to blocklist warm (100) - test setup writes to ADDRESS_B slot NativeCoinAuthorityTest { @@ -1801,36 +1356,20 @@ mod tests { } .abi_encode() .into(), - gas_limit: TRANSFER_GAS_COST, - pre_zero5_gas_limit: None, + gas_limit: TEST_GAS_LIMIT, expected_revert_str: Some(ERR_BLOCKED_ADDRESS), expected_result: InstructionResult::Revert, return_data: None, blocklisted_addresses: Some(HashSet::from([ADDRESS_B])), - gas_used: 2200, // from blocklist cold (2100) + to blocklist warm (100) - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST * 3), - zero6_gas_used: Some(2200 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: 2200 + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, // from blocklist cold (2100) + to blocklist warm (100) target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - ..Default::default() }, ]; for tc in cases { - for hardfork_flags in ArcHardforkFlags::all_combinations() { - // ZeroX hardforks are cumulative; Zero6 implies Zero5. - if hardfork_flags.is_active(ArcHardfork::Zero6) - && !hardfork_flags.is_active(ArcHardfork::Zero5) - { - continue; - } - if tc.eip7708_only && !hardfork_flags.is_active(ArcHardfork::Zero5) { - continue; - } - if tc.pre_eip7708_only && hardfork_flags.is_active(ArcHardfork::Zero5) { - continue; - } - + { + let hardfork_flags = baseline_flags(); let tc_name = tc.name.to_string() + &format!(" (hardfork_flags: {:?})", hardfork_flags); @@ -1844,13 +1383,14 @@ mod tests { scheme: CallScheme::Call, target_address: tc.target_address, bytecode_address: tc.bytecode_address, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(tc.calldata.clone()), - gas_limit: get_gas_limit(tc, hardfork_flags), + gas_limit: tc.gas_limit, caller: tc.caller, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let precompile_res = call_native_coin_authority(&mut ctx, &inputs, hardfork_flags); @@ -1861,9 +1401,57 @@ mod tests { } } + #[test] + fn mint_uses_constant_authority_regardless_of_flags() { + let hardfork_flags = ArcHardforkFlags::default(); + let amount = U256::from(100); + let mut ctx = mock_context(hardfork_flags); + setup_initial_state(&mut ctx, U256::from(1_000_000_000)); + + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: NATIVE_COIN_AUTHORITY_ADDRESS, + bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: ALLOWED_CALLER_ADDRESS, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes( + INativeCoinAuthority::mintCall { + to: ADDRESS_B, + amount, + } + .abi_encode() + .into(), + ), + gas_limit: 100_000, + is_static: false, + return_memory_offset: 0..0, + reservoir: 0, + }; + + let result = call_native_coin_authority(&mut ctx, &inputs, hardfork_flags) + .expect("call should not error") + .expect("result should be Some"); + + assert_eq!(result.result, InstructionResult::Return); + assert_eq!(result.output, Bytes::from(true.abi_encode())); + + let logs = ctx.journal().logs(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].address, SYSTEM_ADDRESS); + let expected_log = Transfer { + from: Address::ZERO, + to: ADDRESS_B, + value: amount, + } + .encode_log_data(); + assert_eq!(logs[0].data, expected_log); + } + #[test] fn mint_side_effects() { - for hardfork_flags in ArcHardforkFlags::all_combinations() { + { + let hardfork_flags = baseline_flags(); // Initial supply let initial_supply = U256::from(1_000_000_000); let mint_amount = U256::from(1000); @@ -1886,7 +1474,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -1900,6 +1488,7 @@ mod tests { gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; // Run precompile @@ -1935,45 +1524,30 @@ mod tests { let journal_mut = ctx.journal_mut(); let logs = journal_mut.logs(); - if hardfork_flags.is_active(ArcHardfork::Zero5) { - // Zero5 (EIP-7708): Transfer(0x0, recipient, amount) replaces NativeCoinMinted - assert_eq!( - logs.len(), - 1, - "Zero5: one EIP-7708 Transfer event expected for mint" - ); - let log = &logs[0]; - assert_eq!( - log.address, SYSTEM_ADDRESS, - "Log should be from EIP-7708 system address" - ); - let expected_log = Transfer { - from: Address::ZERO, - to: ADDRESS_B, - value: mint_amount, - } - .encode_log_data(); - assert_eq!(log.data, expected_log); - } else { - let expected_log = NativeCoinMinted { - recipient: ADDRESS_B, - amount: mint_amount, - } - .encode_log_data(); - assert_eq!(logs.len(), 1, "Expected one log event for mint"); - let log = &logs[0]; - assert_eq!( - log.address, NATIVE_COIN_AUTHORITY_ADDRESS, - "Log address mismatch" - ); - assert_eq!(log.data, expected_log); + assert_eq!( + logs.len(), + 1, + "one EIP-7708 Transfer event expected for mint" + ); + let log = &logs[0]; + assert_eq!( + log.address, SYSTEM_ADDRESS, + "Log should be from EIP-7708 system address" + ); + let expected_log = Transfer { + from: Address::ZERO, + to: ADDRESS_B, + value: mint_amount, } + .encode_log_data(); + assert_eq!(log.data, expected_log); } } #[test] fn burn_side_effects() { - for hardfork_flags in ArcHardforkFlags::all_combinations() { + { + let hardfork_flags = baseline_flags(); // Initial supply and burn amount let initial_supply = U256::from(1_000_000_000); let burn_amount = U256::from(1000); @@ -2004,7 +1578,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -2018,6 +1592,7 @@ mod tests { gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; // Run precompile @@ -2049,8 +1624,7 @@ mod tests { assert_eq!(account_balance, initial_supply - burn_amount); - // For the "current" hardfork, a burn should be a corresponding transfer - // to the zero address. In zero4, it should be a true burn. + // A burn is represented as an EIP-7708 transfer to the zero address. let zero_account_balance = ctx .journal_mut() .load_account(Address::from([0u8; 20])) @@ -2070,45 +1644,30 @@ mod tests { > = ctx.journal_mut(); let logs = journal_mut.logs(); - if hardfork_flags.is_active(ArcHardfork::Zero5) { - // Zero5 (EIP-7708): Transfer(from, 0x0, amount) replaces NativeCoinBurned - assert_eq!( - logs.len(), - 1, - "Zero5: one EIP-7708 Transfer event expected for burn" - ); - let log = &logs[0]; - assert_eq!( - log.address, SYSTEM_ADDRESS, - "Log should be from EIP-7708 system address" - ); - let expected_log = Transfer { - from: ADDRESS_A, - to: Address::ZERO, - value: burn_amount, - } - .encode_log_data(); - assert_eq!(log.data, expected_log); - } else { - let expected_log = NativeCoinBurned { - from: ADDRESS_A, - amount: burn_amount, - } - .encode_log_data(); - assert_eq!(logs.len(), 1, "Expected one log event for burn"); - let log = &logs[0]; - assert_eq!( - log.address, NATIVE_COIN_AUTHORITY_ADDRESS, - "Log address mismatch" - ); - assert_eq!(log.data, expected_log); + assert_eq!( + logs.len(), + 1, + "one EIP-7708 Transfer event expected for burn" + ); + let log = &logs[0]; + assert_eq!( + log.address, SYSTEM_ADDRESS, + "Log should be from EIP-7708 system address" + ); + let expected_log = Transfer { + from: ADDRESS_A, + to: Address::ZERO, + value: burn_amount, } + .encode_log_data(); + assert_eq!(log.data, expected_log); } } #[test] fn transfer_side_effects() { - for hardfork_flags in ArcHardforkFlags::all_combinations() { + { + let hardfork_flags = baseline_flags(); // Initial supply and transfer amount let initial_supply = U256::from(1_000_000_000); let transfer_amount = U256::from(1000); @@ -2138,7 +1697,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -2153,6 +1712,7 @@ mod tests { gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let precompile_res_zero = @@ -2201,7 +1761,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -2216,6 +1776,7 @@ mod tests { gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let precompile_res_nonzero = @@ -2255,41 +1816,22 @@ mod tests { assert_eq!(logs_nonzero.len(), 1, "Expected one log event for transfer"); let log = &logs_nonzero[0]; - if hardfork_flags.is_active(ArcHardfork::Zero5) { - // Zero5 (EIP-7708): ERC-20 Transfer event with EIP-7708 emitter address - let expected_log = Transfer { - from: ADDRESS_A, - to: ADDRESS_B, - value: transfer_amount, - } - .encode_log_data(); - assert_eq!( - log.address, SYSTEM_ADDRESS, - "Zero5: log emitter should be SYSTEM_ADDRESS" - ); - assert_eq!(log.data, expected_log); - } else { - // Pre-Zero5: NativeCoinTransferred event with native coin authority address - let expected_log = NativeCoinTransferred { - from: ADDRESS_A, - to: ADDRESS_B, - amount: transfer_amount, - } - .encode_log_data(); - assert_eq!( - log.address, NATIVE_COIN_AUTHORITY_ADDRESS, - "Pre-Zero5: log emitter should be NATIVE_COIN_AUTHORITY_ADDRESS" - ); - assert_eq!(log.data, expected_log); + let expected_log = Transfer { + from: ADDRESS_A, + to: ADDRESS_B, + value: transfer_amount, } + .encode_log_data(); + assert_eq!(log.address, SYSTEM_ADDRESS); + assert_eq!(log.data, expected_log); } } - /// EIP-7708: self-transfers (from == to) do not emit a Transfer log under Zero5. - /// Pre-Zero5: self-transfers still emit NativeCoinTransferred. + /// Self-transfers (from == to) do not emit an EIP-7708 Transfer log. #[test] - fn transfer_self_transfer_no_log_under_zero5() { - for hardfork_flags in ArcHardforkFlags::all_combinations() { + fn transfer_self_transfer_no_log() { + { + let hardfork_flags = baseline_flags(); let initial_supply = U256::from(1_000_000_000); let transfer_amount = U256::from(1000); @@ -2301,7 +1843,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -2316,6 +1858,7 @@ mod tests { gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let precompile_res = call_native_coin_authority(&mut ctx, &inputs, hardfork_flags); @@ -2334,126 +1877,20 @@ mod tests { let logs = ctx.journal().logs(); - if hardfork_flags.is_active(ArcHardfork::Zero5) { - // Zero5 + EIP-7708: self-transfers do not emit a log - assert_eq!(logs.len(), 0, "Zero5: self-transfer should not emit a log"); - - // Self-transfer still executes the full transfer path (balance_decr + - // balance_incr) to preserve gas invariants across the Zero5 boundary — - // only event emission is suppressed. Under Zero6, the transfer() helper - // uses warm/cold account-load pricing: ADDRESS_A is pre-warmed by the - // test setup, so both account loads hit the warm path. - let (from_account_load, to_account_load) = - if hardfork_flags.is_active(ArcHardfork::Zero6) { - ( - revm_interpreter::gas::WARM_STORAGE_READ_COST, - revm_interpreter::gas::WARM_STORAGE_READ_COST, - ) - } else { - (PRECOMPILE_SLOAD_GAS_COST, PRECOMPILE_SLOAD_GAS_COST) - }; - let expected_gas = 2100 - + 100 - + from_account_load - + to_account_load - + 2 * PRECOMPILE_SSTORE_GAS_COST; - assert_eq!( - result.gas.used(), - expected_gas, - "Zero5: self-transfer gas should include transfer path but no event (flags={hardfork_flags:?})", - ); - } else { - // Pre-Zero5: self-transfers still emit NativeCoinTransferred - assert_eq!( - logs.len(), - 1, - "Pre-Zero5: self-transfer emits NativeCoinTransferred" - ); - let log = &logs[0]; - assert_eq!(log.address, NATIVE_COIN_AUTHORITY_ADDRESS); - let expected_log = NativeCoinTransferred { - from: ADDRESS_A, - to: ADDRESS_A, - amount: transfer_amount, - } - .encode_log_data(); - assert_eq!(log.data, expected_log); - - // Gas accounting: auth SLOAD + 2 blocklist SLOADs + transfer + event. - // Under Zero6, transfer()'s account loads use warm/cold pricing — - // ADDRESS_A is pre-warmed by setup, so both loads hit the warm path. - let zero6_account_load_delta = if hardfork_flags.is_active(ArcHardfork::Zero6) { - 2 * (PRECOMPILE_SLOAD_GAS_COST - revm_interpreter::gas::WARM_STORAGE_READ_COST) - } else { - 0 - }; - assert_eq!( - result.gas.used(), - TRANSFER_GAS_COST - zero6_account_load_delta, - "Pre-Zero5: self-transfer gas should match TRANSFER_GAS_COST (flags={hardfork_flags:?})", - ); - } - } - } - - #[test] - fn transfer_recipient_overflow_preserves_pre_zero6_gas() { - for hardfork_flags in ArcHardforkFlags::all_combinations() { - if hardfork_flags.is_active(ArcHardfork::Zero6) { - continue; - } + assert_eq!(logs.len(), 0, "self-transfer should not emit a log"); - let mut ctx = mock_context(hardfork_flags); - setup_initial_state(&mut ctx, U256::from(1_000_000_000)); - ctx.journal_mut() - .load_account(ADDRESS_B) - .expect("Cannot load recipient account"); - ctx.journal_mut() - .balance_incr(ADDRESS_B, U256::MAX) - .expect("Unable to set recipient max balance"); - - let inputs = CallInputs { - scheme: CallScheme::Call, - target_address: NATIVE_COIN_AUTHORITY_ADDRESS, - bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, - caller: ALLOWED_CALLER_ADDRESS, - value: CallValue::Transfer(U256::ZERO), - input: CallInput::Bytes( - INativeCoinAuthority::transferCall { - from: ADDRESS_A, - to: ADDRESS_B, - amount: U256::from(1), - } - .abi_encode() - .into(), - ), - gas_limit: 100_000, - is_static: false, - return_memory_offset: 0..0, - }; - - let result = call_native_coin_authority(&mut ctx, &inputs, hardfork_flags) - .expect("call should not error") - .expect("call should return interpreter result"); - - assert_eq!(result.result, InstructionResult::Revert); + // Self-transfer still executes the full transfer path (balance_decr + + // balance_incr); only event emission is suppressed. ADDRESS_A is + // pre-warmed by the test setup, so both account loads hit the warm path. + let expected_gas = 2100 + + 100 + + 2 * revm_interpreter::gas::WARM_STORAGE_READ_COST + + 2 * PRECOMPILE_SSTORE_GAS_COST; assert_eq!( - bytes_to_revert_message(result.output.as_ref()).as_deref(), - Some(ERR_OVERFLOW), + result.gas.used(), + expected_gas, + "self-transfer gas should include transfer path but no event" ); - - let expected_gas = if hardfork_flags.is_active(ArcHardfork::Zero5) { - 2 * PRECOMPILE_SLOAD_GAS_COST - + 2 * PRECOMPILE_SLOAD_GAS_COST - + 2 * PRECOMPILE_SSTORE_GAS_COST - } else { - 3 * PRECOMPILE_SLOAD_GAS_COST - + 2 * PRECOMPILE_SLOAD_GAS_COST - + 2 * PRECOMPILE_SSTORE_GAS_COST - }; - assert_eq!(result.gas.used(), expected_gas); - assert_eq!(result.gas.refunded(), 0); } } @@ -2506,7 +1943,8 @@ mod tests { ), ]; - for hardfork_flags in ArcHardforkFlags::all_combinations() { + { + let hardfork_flags = baseline_flags(); // State-modifying functions must revert under static call for (fn_name, calldata) in state_modifying_calldatas { let mut ctx = mock_context(hardfork_flags); @@ -2514,13 +1952,14 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(calldata.clone()), gas_limit: 100_000, is_static: true, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_native_coin_authority(&mut ctx, &inputs, hardfork_flags) @@ -2538,6 +1977,13 @@ mod tests { Some(ERR_STATE_CHANGE_DURING_STATIC_CALL), "{fn_name} (hardfork_flags: {hardfork_flags:?}): wrong revert reason", ); + // spend_all() must consume the entire gas limit before reverting, so + // static callers cannot probe stateful precompile paths cheaply. + assert_eq!( + result.gas.used(), + inputs.gas_limit, + "{fn_name} (hardfork_flags: {hardfork_flags:?}): static-call revert must spend all gas", + ); } // Read-only function (totalSupply) must succeed under static call @@ -2547,7 +1993,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ADDRESS_A, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -2556,6 +2002,7 @@ mod tests { gas_limit: 100_000, is_static: true, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_native_coin_authority(&mut ctx, &inputs, hardfork_flags) @@ -2574,7 +2021,7 @@ mod tests { #[test] fn transfer_or_mint_to_selfdestructed_account_should_revert() { let amount = U256::from(1000); - let hardfork_flags = ArcHardforkFlags::with(&[ArcHardfork::Zero4, ArcHardfork::Zero5]); + let hardfork_flags = baseline_flags(); let mut ctx = mock_context(hardfork_flags); let spec_id = ctx.cfg.spec; @@ -2597,7 +2044,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -2611,6 +2058,7 @@ mod tests { gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; // Mint to destructed account should revert @@ -2645,49 +2093,83 @@ mod tests { ); } - fn total_supply_calldata_with_trailing_bytes() -> Bytes { - let mut calldata = Vec::with_capacity(4 + 32); - calldata.extend_from_slice(&INativeCoinAuthority::totalSupplyCall::SELECTOR); - calldata.extend_from_slice(&[0u8; 32]); - calldata.into() - } - #[test] - fn total_supply_rejects_extra_input_pre_zero6() { - for hardfork_flags in ArcHardforkFlags::all_combinations() { - if hardfork_flags.is_active(ArcHardfork::Zero6) { - continue; - } + fn delegatecall_charges_early_revert_penalty_only_under_zero8() { + let calldata = INativeCoinAuthority::mintCall { + to: ADDRESS_B, + amount: U256::from(1), + } + .abi_encode(); + let gas_used_for = |hardfork_flags: ArcHardforkFlags| -> u64 { let mut ctx = mock_context(hardfork_flags); let inputs = CallInputs { scheme: CallScheme::Call, - target_address: NATIVE_COIN_AUTHORITY_ADDRESS, + target_address: ADDRESS_A, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, - caller: ADDRESS_A, + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), - input: CallInput::Bytes(total_supply_calldata_with_trailing_bytes()), - gas_limit: 100_000, + input: CallInput::Bytes(calldata.clone().into()), + gas_limit: 1_000_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_native_coin_authority(&mut ctx, &inputs, hardfork_flags) .expect("call should not error") .expect("result should be Some"); - - assert_eq!( - result.result, - InstructionResult::Revert, - "({hardfork_flags:?}): expected Revert with trailing calldata pre-Zero6", - ); + assert_eq!(result.result, InstructionResult::Revert); assert_eq!( bytes_to_revert_message(result.output.as_ref()).as_deref(), - Some(ERR_EXECUTION_REVERTED), - "({hardfork_flags:?}): expected execution reverted message", + Some(ERR_DELEGATE_CALL_NOT_ALLOWED), ); - } + result.gas.used() + }; + + let zero8_flags = ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ArcHardfork::Zero8, + ]); + let zero7_flags = ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ]); + + assert_eq!( + gas_used_for(baseline_flags()), + 0, + "pre-Zero7 delegatecall rejection charges no penalty", + ); + assert_eq!( + gas_used_for(zero7_flags), + 0, + "Zero7 without Zero8 delegatecall rejection charges no penalty", + ); + assert_eq!( + gas_used_for(zero8_flags), + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + "Zero8 delegatecall rejection charges the early-revert penalty", + ); + } + + fn total_supply_calldata_with_trailing_bytes(trailing: &[u8]) -> Bytes { + let capacity = INativeCoinAuthority::totalSupplyCall::SELECTOR + .len() + .checked_add(trailing.len()) + .expect("selector plus test trailing bytes length should fit"); + let mut calldata = Vec::with_capacity(capacity); + calldata.extend_from_slice(&INativeCoinAuthority::totalSupplyCall::SELECTOR); + calldata.extend_from_slice(trailing); + calldata.into() } /// Under Zero6, account helpers (`transfer`, `balance_incr`, `balance_decr`) @@ -2716,7 +2198,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -2730,6 +2212,7 @@ mod tests { gas_limit, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; /// Pre-fund cold_target so it is non-empty (avoids Zero6 empty-account @@ -2802,14 +2285,17 @@ mod tests { } #[test] - fn total_supply_accepts_extra_input_with_zero6() { + fn total_supply_accepts_extra_input() { let mock_initial_supply = U256::from(1_000_000_000); + let cases: &[(&str, &[u8])] = &[ + ("empty", &[]), + ("zero word", &[0u8; 32]), + ("non-zero partial word", &[0xab, 0xcd, 0x01]), + ("non-zero unaligned long", &[0x11; 33]), + ]; - for hardfork_flags in ArcHardforkFlags::all_combinations() { - if !hardfork_flags.is_active(ArcHardfork::Zero6) { - continue; - } - + for (case_name, trailing) in cases { + let hardfork_flags = baseline_flags(); let mut ctx = mock_context(hardfork_flags); setup_initial_state(&mut ctx, mock_initial_supply); @@ -2817,13 +2303,14 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ADDRESS_A, value: CallValue::Transfer(U256::ZERO), - input: CallInput::Bytes(total_supply_calldata_with_trailing_bytes()), + input: CallInput::Bytes(total_supply_calldata_with_trailing_bytes(trailing)), gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_native_coin_authority(&mut ctx, &inputs, hardfork_flags) @@ -2833,12 +2320,76 @@ mod tests { assert_eq!( result.result, InstructionResult::Return, - "({hardfork_flags:?}): expected Return with trailing calldata under Zero6", + "{case_name} ({hardfork_flags:?}): expected Return with trailing calldata", ); let returned = U256::abi_decode(result.output.as_ref()).expect("decode total supply"); assert_eq!( returned, mock_initial_supply, - "({hardfork_flags:?}): expected initial supply returned", + "{case_name} ({hardfork_flags:?}): expected initial supply returned", + ); + } + } + + #[test] + fn database_errors_abort_native_coin_authority_calls() { + use crate::helpers::test_utils::{ + assert_provider_db_error_is_fatal, FailingDB, FailingDbMode, + }; + + let hardfork_flags = baseline_flags(); + let cases: &[(&str, Bytes)] = &[ + ( + "mint", + INativeCoinAuthority::mintCall { + to: ADDRESS_B, + amount: U256::from(1), + } + .abi_encode() + .into(), + ), + ( + "burn", + INativeCoinAuthority::burnCall { + from: ADDRESS_A, + amount: U256::from(1), + } + .abi_encode() + .into(), + ), + ( + "transfer", + INativeCoinAuthority::transferCall { + from: ADDRESS_A, + to: ADDRESS_B, + amount: U256::from(1), + } + .abi_encode() + .into(), + ), + ( + "totalSupply", + INativeCoinAuthority::totalSupplyCall {}.abi_encode().into(), + ), + ]; + + for (name, calldata) in cases { + let mut ctx = FailingDB::context(FailingDbMode::Storage); + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: NATIVE_COIN_AUTHORITY_ADDRESS, + bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: ALLOWED_CALLER_ADDRESS, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.clone()), + gas_limit: TEST_GAS_LIMIT, + is_static: false, + return_memory_offset: 0..0, + reservoir: 0, + }; + assert_provider_db_error_is_fatal( + call_native_coin_authority(&mut ctx, &inputs, hardfork_flags), + name, ); } } diff --git a/crates/precompiles/src/native_coin_control.rs b/crates/precompiles/src/native_coin_control.rs index 211ff27c..3d3256f6 100644 --- a/crates/precompiles/src/native_coin_control.rs +++ b/crates/precompiles/src/native_coin_control.rs @@ -20,17 +20,15 @@ //! blocklisting and unblocklisting addresses from receiving native coin transfers. use crate::helpers::{ - abi_decode_raw_with_zero6_validation, check_delegatecall, check_gas_remaining, - check_staticcall, emit_event, new_reverted_with_early_penalty, read, write, - PrecompileErrorOrRevert, ERR_EXECUTION_REVERTED, LOG_BASE_COST, LOG_TOPIC_COST, - NATIVE_FIAT_TOKEN_ADDRESS, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, PRECOMPILE_SLOAD_GAS_COST, - PRECOMPILE_SSTORE_GAS_COST, + abi_decode_raw_validated, check_delegatecall, check_gas_remaining, check_staticcall, + emit_event, new_reverted_with_early_penalty, read, write, PrecompileErrorOrRevert, + ERR_EXECUTION_REVERTED, LOG_BASE_COST, LOG_TOPIC_COST, NATIVE_FIAT_TOKEN_ADDRESS, + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, PRECOMPILE_SLOAD_GAS_COST, PRECOMPILE_SSTORE_GAS_COST, }; use crate::precompile; -use alloy_evm::EvmInternals; use alloy_primitives::{address, Address, StorageKey, U256}; use alloy_sol_types::{sol, SolCall, SolValue}; -use arc_execution_config::hardforks::{ArcHardfork, ArcHardforkFlags}; +use arc_execution_config::hardforks::ArcHardfork; use arc_execution_config::native_coin_control as native_coin_control_config; use reth_ethereum::evm::revm::precompile::PrecompileOutput; use revm_interpreter::Gas; @@ -45,21 +43,13 @@ const ALLOWED_CALLER_ADDRESS: Address = NATIVE_FIAT_TOKEN_ADDRESS; /// Exported error message / revert string pub const BLOCKLISTED_ERROR_MESSAGE: &str = "address is blocklisted"; -// Storage key for allowed caller (deprecated since Zero5) -const ALLOWED_CALLER_STORAGE_KEY: StorageKey = StorageKey::new([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -]); - // Gas costs const BLOCKLISTED_EVENT_GAS_COST: u64 = LOG_BASE_COST + 2 * LOG_TOPIC_COST; // 2 topics const UNBLOCKLISTED_EVENT_GAS_COST: u64 = LOG_BASE_COST + 2 * LOG_TOPIC_COST; // 2 topics -// Total gas costs for each operation - -// - Reading allowed caller (2100 gas) -// - Writing blocklist storage (2900 gas) -// - Emitting event (1125 gas) -// Total: 6125 gas +// Success-path gas floor for blocklist operations. The baseline path no longer +// performs an auth SLOAD, so callers subtract PRECOMPILE_SLOAD_GAS_COST before +// checking this floor. const BLOCKLIST_GAS_COST: u64 = PRECOMPILE_SLOAD_GAS_COST + PRECOMPILE_SSTORE_GAS_COST + BLOCKLISTED_EVENT_GAS_COST; @@ -67,10 +57,7 @@ const BLOCKLIST_GAS_COST: u64 = // Total: 2100 gas pub const IS_BLOCKLISTED_GAS_COST: u64 = PRECOMPILE_SLOAD_GAS_COST; -// - Reading allowed caller (2100 gas) -// - Writing blocklist storage (2900 gas) -// - Emitting event (1125 gas) -// Total: 6125 gas +// Success-path gas floor for unblocklist operations. See BLOCKLIST_GAS_COST. const UNBLOCKLIST_GAS_COST: u64 = PRECOMPILE_SLOAD_GAS_COST + PRECOMPILE_SSTORE_GAS_COST + UNBLOCKLISTED_EVENT_GAS_COST; @@ -103,28 +90,6 @@ sol! { event UnBlocklisted(address indexed account); } -/// Checks if the caller is authorized to call mutative native coin control functions -fn is_authorized( - internals: &mut EvmInternals, - caller: Address, - gas_counter: &mut Gas, - hardfork_flags: ArcHardforkFlags, -) -> Result { - // Get allowed caller - let allowed_caller_output = read( - internals, - NATIVE_COIN_CONTROL_ADDRESS, - ALLOWED_CALLER_STORAGE_KEY, - gas_counter, - hardfork_flags, - )?; - - // Compare caller to allowed_caller_output - let caller_word = U256::from_be_slice(caller.as_ref()); - let allowed_caller_word = U256::from_be_slice(&allowed_caller_output); - Ok(caller_word == allowed_caller_word) -} - /// Computes the storage slot for a mapping key of type address /// /// Delegates to the execution-config canonical implementation. @@ -136,6 +101,7 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { INativeCoinControl::blocklistCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); + let reservoir = precompile_input.reservoir; let mut precompile_input = precompile_input; // Check if static call is attempting to modify state @@ -145,69 +111,47 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { )?; // Decode arguments passed to blocklist function - let args = abi_decode_raw_with_zero6_validation::( - input, - hardfork_flags, - ) + let args = abi_decode_raw_validated::(input) .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, ) )?; - if hardfork_flags.is_active(ArcHardfork::Zero5) { - if hardfork_flags.is_active(ArcHardfork::Zero6) { - // Auth first so the Zero6 early-revert penalty is reachable - // regardless of remaining gas; otherwise the success-path - // floor below OOGs callers in the 200..4024 gas window. - if precompile_input.caller != ALLOWED_CALLER_ADDRESS { - return Err(new_reverted_with_early_penalty( - gas_counter, - ERR_CANNOT_BLOCKLIST, - hardfork_flags, - )); - } - check_gas_remaining( - &gas_counter, - BLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, - )?; - } else { - // Zero5-only: keep the original order to preserve consensus - // on networks already past the Zero5 activation block. - check_gas_remaining( - &gas_counter, - BLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, - )?; - if precompile_input.caller != ALLOWED_CALLER_ADDRESS { - return Err(new_reverted_with_early_penalty( - gas_counter, - ERR_CANNOT_BLOCKLIST, - hardfork_flags, - )); - } - } - } else { - // Early return if not enough gas - check_gas_remaining(&gas_counter, BLOCKLIST_GAS_COST)?; - - // Check authorization - if !(is_authorized( - &mut precompile_input.internals, - precompile_input.caller, - &mut gas_counter, - hardfork_flags, - )?) { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_CANNOT_BLOCKLIST, hardfork_flags)); - } + // Auth first so the early-revert penalty is reachable regardless of + // remaining gas; otherwise the success-path floor below OOGs callers + // in the 200..4024 gas window. + if precompile_input.caller != ALLOWED_CALLER_ADDRESS { + return Err(new_reverted_with_early_penalty( + gas_counter, + reservoir, + ERR_CANNOT_BLOCKLIST, + )); } - // Check delegate call - check_delegatecall( - NATIVE_COIN_CONTROL_ADDRESS, - &precompile_input, + if hardfork_flags.is_active(ArcHardfork::Zero8) { + check_delegatecall( + NATIVE_COIN_CONTROL_ADDRESS, + &precompile_input, + &gas_counter, + hardfork_flags, + )?; + } + check_gas_remaining( &gas_counter, + reservoir, + BLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, )?; + if !hardfork_flags.is_active(ArcHardfork::Zero8) { + check_delegatecall( + NATIVE_COIN_CONTROL_ADDRESS, + &precompile_input, + &gas_counter, + hardfork_flags, + )?; + } + // Add to blocklist let storage_slot = compute_is_blocklisted_storage_slot(args.account); write( @@ -216,7 +160,7 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { storage_slot, &BLOCKLISTED_STATUS.to_be_bytes_vec(), &mut gas_counter, - hardfork_flags, + reservoir, )?; // Emit event @@ -227,32 +171,31 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { account: args.account, }, &mut gas_counter, + reservoir, )?; let output = true.abi_encode(); - Ok(PrecompileOutput::new(gas_counter.used(), output.into())) + Ok(PrecompileOutput::new(gas_counter.used(), output.into(), reservoir)) })() }, INativeCoinControl::isBlocklistedCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); + let reservoir = precompile_input.reservoir; let mut precompile_input = precompile_input; // Decode arguments passed to isBlocklisted function let args = - abi_decode_raw_with_zero6_validation::( - input, - hardfork_flags, - ) + abi_decode_raw_validated::(input) .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, ) )?; // Early return if not enough gas - check_gas_remaining(&gas_counter, IS_BLOCKLISTED_GAS_COST)?; + check_gas_remaining(&gas_counter, reservoir, IS_BLOCKLISTED_GAS_COST)?; // Check if address is blocklisted let storage_slot = compute_is_blocklisted_storage_slot(args.account); @@ -261,7 +204,7 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { NATIVE_COIN_CONTROL_ADDRESS, storage_slot, &mut gas_counter, - hardfork_flags, + reservoir, )?; let status = U256::from_be_slice(&storage_output); @@ -269,13 +212,14 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { let is_blocked = status != UNBLOCKLISTED_STATUS; let output = is_blocked.abi_encode(); - Ok(PrecompileOutput::new(gas_counter.used(), output.into())) + Ok(PrecompileOutput::new(gas_counter.used(), output.into(), reservoir)) })() }, INativeCoinControl::unBlocklistCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); + let reservoir = precompile_input.reservoir; let mut precompile_input = precompile_input; // Check if static call is attempting to modify state @@ -285,69 +229,47 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { )?; // Decode arguments passed to unBlocklist function - let args = abi_decode_raw_with_zero6_validation::( - input, - hardfork_flags, - ) + let args = abi_decode_raw_validated::(input) .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, ) )?; - if hardfork_flags.is_active(ArcHardfork::Zero5) { - if hardfork_flags.is_active(ArcHardfork::Zero6) { - // Auth first so the Zero6 early-revert penalty is reachable - // regardless of remaining gas; otherwise the success-path - // floor below OOGs callers in the 200..4024 gas window. - if precompile_input.caller != ALLOWED_CALLER_ADDRESS { - return Err(new_reverted_with_early_penalty( - gas_counter, - ERR_CANNOT_UNBLOCKLIST, - hardfork_flags, - )); - } - check_gas_remaining( - &gas_counter, - UNBLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, - )?; - } else { - // Zero5-only: keep the original order to preserve consensus - // on networks already past the Zero5 activation block. - check_gas_remaining( - &gas_counter, - UNBLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, - )?; - if precompile_input.caller != ALLOWED_CALLER_ADDRESS { - return Err(new_reverted_with_early_penalty( - gas_counter, - ERR_CANNOT_UNBLOCKLIST, - hardfork_flags, - )); - } - } - } else { - // Early return if not enough gas - check_gas_remaining(&gas_counter, UNBLOCKLIST_GAS_COST)?; - - // Check authorization - if !(is_authorized( - &mut precompile_input.internals, - precompile_input.caller, - &mut gas_counter, - hardfork_flags, - )?) { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_CANNOT_UNBLOCKLIST, hardfork_flags)); - } + // Auth first so the early-revert penalty is reachable regardless of + // remaining gas; otherwise the success-path floor below OOGs callers + // in the 200..4024 gas window. + if precompile_input.caller != ALLOWED_CALLER_ADDRESS { + return Err(new_reverted_with_early_penalty( + gas_counter, + reservoir, + ERR_CANNOT_UNBLOCKLIST, + )); } - // Check delegate call - check_delegatecall( - NATIVE_COIN_CONTROL_ADDRESS, - &precompile_input, + if hardfork_flags.is_active(ArcHardfork::Zero8) { + check_delegatecall( + NATIVE_COIN_CONTROL_ADDRESS, + &precompile_input, + &gas_counter, + hardfork_flags, + )?; + } + check_gas_remaining( &gas_counter, + reservoir, + UNBLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, )?; + if !hardfork_flags.is_active(ArcHardfork::Zero8) { + check_delegatecall( + NATIVE_COIN_CONTROL_ADDRESS, + &precompile_input, + &gas_counter, + hardfork_flags, + )?; + } + // Remove from blocklist let storage_slot = compute_is_blocklisted_storage_slot(args.account); write( @@ -356,7 +278,7 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { storage_slot, &UNBLOCKLISTED_STATUS.to_be_bytes_vec(), &mut gas_counter, - hardfork_flags, + reservoir, )?; // Emit event @@ -367,21 +289,22 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { account: args.account, }, &mut gas_counter, + reservoir, )?; let output = true.abi_encode(); - Ok(PrecompileOutput::new(gas_counter.used(), output.into())) + Ok(PrecompileOutput::new(gas_counter.used(), output.into(), reservoir)) })() }, }); #[cfg(test)] mod tests { - use crate::helpers::ERR_DELEGATE_CALL_NOT_ALLOWED; - use arc_execution_config::hardforks::ArcHardforkFlags; + use crate::helpers::{ERR_DELEGATE_CALL_NOT_ALLOWED, PRECOMPILE_EARLY_REVERT_GAS_PENALTY}; + use arc_execution_config::hardforks::{ArcHardfork, ArcHardforkFlags}; use super::*; - use alloy_primitives::Bytes; + use alloy_primitives::{Bytes, B256}; use alloy_sol_types::SolEvent; use reth_ethereum::evm::revm::{ context::{Context, ContextTr, JournalTr}, @@ -390,36 +313,36 @@ mod tests { }; use reth_evm::precompiles::{DynPrecompile, PrecompilesMap}; use revm::{ + bytecode::Bytecode, handler::PrecompileProvider, interpreter::InterpreterResult, precompile::{PrecompileId, Precompiles}, }; - fn mock_context(hardfork_flags: ArcHardforkFlags) -> revm::Context { + fn mock_context(_hardfork_flags: ArcHardforkFlags) -> revm::Context { let mut ctx = Context::mainnet(); ctx.journal_mut() .load_account(NATIVE_COIN_CONTROL_ADDRESS) .expect("Unable to load native coin control account"); - if !hardfork_flags.is_active(ArcHardfork::Zero5) { - ctx.journal_mut() - .sstore( - NATIVE_COIN_CONTROL_ADDRESS, - ALLOWED_CALLER_STORAGE_KEY.into(), - U256::from_be_slice(ALLOWED_CALLER_ADDRESS.as_ref()), - ) - .expect("Unable to write allowed caller"); - } - ctx } - fn call_native_coin_control( - ctx: &mut Context, + fn call_native_coin_control( + ctx: &mut revm::context::Context< + revm::context::BlockEnv, + revm::context::TxEnv, + revm::context::CfgEnv, + DB, + revm::context::Journal, + >, inputs: &CallInputs, hardfork_flags: ArcHardforkFlags, - ) -> Result, String> { + ) -> Result, String> + where + DB: revm::database_interface::Database + std::fmt::Debug, + { let mut provider = PrecompilesMap::from_static(Precompiles::latest()); let target_addr: Address = inputs.target_address; provider.set_precompile_lookup(move |address: &Address| { @@ -445,29 +368,27 @@ mod tests { expected_revert_str: Option<&'static str>, return_data: Option, gas_used: u64, - /// If set, overrides gas_used for pre-Zero5 hardforks (before EIP-2929/2200 storage costs) - pre_zero5_gas_used: Option, - /// If set, overrides gas_used when Zero6 is active (auth/validation reverts now - /// charge `PRECOMPILE_EARLY_REVERT_GAS_PENALTY`). - zero6_gas_used: Option, target_address: Address, bytecode_address: Address, - /// If true, skip this test case for hardfork combinations without Zero6. - /// Used for cases whose result shape differs under Zero6 (e.g. penalty - /// revert vs OOG for low-gas unauthorized calls). - zero6_only: bool, - /// If true, skip this test case for hardfork combinations with Zero6. - pre_zero6_only: bool, } // Test constants const ADDRESS_A: Address = address!("1000000000000000000000000000000000000001"); const ADDRESS_B: Address = address!("2000000000000000000000000000000000000002"); + fn baseline_flags() -> ArcHardforkFlags { + ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ]) + } + fn assert_precompile_result( precompile_res: Result, String>, tc: &NativeCoinControlTest, - hardfork_flags: ArcHardforkFlags, + _hardfork_flags: ArcHardforkFlags, tc_name: &str, ) { match precompile_res { @@ -506,18 +427,16 @@ mod tests { ); } - let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero6) { - tc.zero6_gas_used.unwrap_or(tc.gas_used) - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - tc.gas_used - } else { - tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) - }; - assert_eq!( - result.gas.used(), - expected_gas_used, - "{tc_name}: gas used to match" - ); + // Skip the gas-used assertion on PrecompileOOG: under revm 38 the + // precompile-result converter always `spend_all()`s on Halt, so + // `result.gas.used()` is tautologically the gas_limit. + if tc.expected_result != InstructionResult::PrecompileOOG { + assert_eq!( + result.gas.used(), + tc.gas_used, + "{tc_name}: gas used to match" + ); + } } Err(e) => { panic!("{tc_name}: unexpected error {:?}", e) @@ -540,14 +459,10 @@ mod tests { expected_revert_str: None, return_data: Some(true.abi_encode().into()), gas_used: 23225, - pre_zero5_gas_used: Some(BLOCKLIST_GAS_COST), - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, - // Reverts before storage ops, 0 gas + // Reverts before storage ops NativeCoinControlTest { name: "blocklist() unauthorized caller reverts", caller: ADDRESS_A, @@ -558,11 +473,7 @@ mod tests { expected_result: InstructionResult::Revert, expected_revert_str: Some(ERR_CANNOT_BLOCKLIST), return_data: None, - gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), - zero6_only: false, - pre_zero6_only: false, + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -577,10 +488,6 @@ mod tests { expected_revert_str: None, return_data: None, gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -593,14 +500,10 @@ mod tests { expected_revert_str: Some(ERR_EXECUTION_REVERTED), return_data: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, - // Reverts before storage ops, 0 gas + // Reverts before storage ops NativeCoinControlTest { name: "blocklist() with target address != precompile address reverts", caller: ALLOWED_CALLER_ADDRESS, @@ -612,14 +515,10 @@ mod tests { expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), return_data: None, gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: ADDRESS_B, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, - // Reverts before storage ops, 0 gas + // Reverts before storage ops NativeCoinControlTest { name: "blocklist() with bytecode address != precompile address reverts", caller: ALLOWED_CALLER_ADDRESS, @@ -631,14 +530,10 @@ mod tests { expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), return_data: None, gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: ADDRESS_B, }, - // SLOAD cold = 2100 (same for Zero5 and pre-Zero5) + // SLOAD cold = 2100 NativeCoinControlTest { name: "isBlocklisted() returns false for non-blocklisted address", caller: ADDRESS_A, // Authorization not required for view function @@ -650,10 +545,6 @@ mod tests { expected_revert_str: None, return_data: Some(false.abi_encode().into()), gas_used: PRECOMPILE_SLOAD_GAS_COST, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -668,10 +559,6 @@ mod tests { expected_revert_str: None, return_data: None, gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -684,10 +571,6 @@ mod tests { expected_revert_str: Some(ERR_EXECUTION_REVERTED), return_data: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -703,14 +586,10 @@ mod tests { expected_revert_str: None, return_data: Some(true.abi_encode().into()), gas_used: 3325, - pre_zero5_gas_used: Some(UNBLOCKLIST_GAS_COST), - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, - // Reverts before storage ops, 0 gas + // Reverts before storage ops NativeCoinControlTest { name: "unBlocklist() with target != precompile address reverts", caller: ALLOWED_CALLER_ADDRESS, @@ -722,14 +601,10 @@ mod tests { expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), return_data: None, gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: ADDRESS_B, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, - // Reverts before storage ops, 0 gas + // Reverts before storage ops NativeCoinControlTest { name: "unBlocklist() with bytecode_address != precompile address reverts", caller: ALLOWED_CALLER_ADDRESS, @@ -741,14 +616,10 @@ mod tests { expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), return_data: None, gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: ADDRESS_B, }, - // Reverts before storage ops, 0 gas + // Reverts before storage ops NativeCoinControlTest { name: "unBlocklist() unauthorized caller reverts", caller: ADDRESS_A, @@ -759,11 +630,7 @@ mod tests { expected_result: InstructionResult::Revert, expected_revert_str: Some(ERR_CANNOT_UNBLOCKLIST), return_data: None, - gas_used: 0, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST), - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), - zero6_only: false, - pre_zero6_only: false, + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -778,10 +645,6 @@ mod tests { expected_revert_str: None, return_data: None, gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -794,18 +657,14 @@ mod tests { expected_revert_str: Some(ERR_EXECUTION_REVERTED), return_data: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, - // Under Zero6, the auth check runs before the success-path gas + // Authorization reverts before the success-path gas // floor, so an unauthorized caller with gas >= 200 (the penalty) // gets a penalized revert — not an OOG. NativeCoinControlTest { - name: "blocklist() Zero6 low-gas unauthorized reverts with penalty", + name: "blocklist() low-gas unauthorized reverts with penalty", caller: ADDRESS_A, calldata: INativeCoinControl::blocklistCall { account: ADDRESS_B } .abi_encode() @@ -815,36 +674,11 @@ mod tests { expected_revert_str: Some(ERR_CANNOT_BLOCKLIST), return_data: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: true, - pre_zero6_only: false, - target_address: NATIVE_COIN_CONTROL_ADDRESS, - bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, - }, - // Pre-Zero6 (Zero3/Zero4/Zero5): gas floor runs before auth, so a - // low-gas unauthorized caller OOGs. Locks in the historical Zero5 - // behavior on devnet. - NativeCoinControlTest { - name: "blocklist() pre-Zero6 low-gas unauthorized OOGs", - caller: ADDRESS_A, - calldata: INativeCoinControl::blocklistCall { account: ADDRESS_B } - .abi_encode() - .into(), - gas_limit: 500, - expected_result: InstructionResult::PrecompileOOG, - expected_revert_str: None, - return_data: None, - gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: true, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, NativeCoinControlTest { - name: "unBlocklist() Zero6 low-gas unauthorized reverts with penalty", + name: "unBlocklist() low-gas unauthorized reverts with penalty", caller: ADDRESS_A, calldata: INativeCoinControl::unBlocklistCall { account: ADDRESS_B } .abi_encode() @@ -854,49 +688,14 @@ mod tests { expected_revert_str: Some(ERR_CANNOT_UNBLOCKLIST), return_data: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: true, - pre_zero6_only: false, - target_address: NATIVE_COIN_CONTROL_ADDRESS, - bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, - }, - NativeCoinControlTest { - name: "unBlocklist() pre-Zero6 low-gas unauthorized OOGs", - caller: ADDRESS_A, - calldata: INativeCoinControl::unBlocklistCall { account: ADDRESS_B } - .abi_encode() - .into(), - gas_limit: 500, - expected_result: InstructionResult::PrecompileOOG, - expected_revert_str: None, - return_data: None, - gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: None, - zero6_only: false, - pre_zero6_only: true, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, ]; for tc in cases { - for hardfork_flags in ArcHardforkFlags::all_combinations() { - // ZeroX hardforks are cumulative; Zero6 implies Zero5. - if hardfork_flags.is_active(ArcHardfork::Zero6) - && !hardfork_flags.is_active(ArcHardfork::Zero5) - { - continue; - } - - if tc.zero6_only && !hardfork_flags.is_active(ArcHardfork::Zero6) { - continue; - } - if tc.pre_zero6_only && hardfork_flags.is_active(ArcHardfork::Zero6) { - continue; - } - + { + let hardfork_flags = baseline_flags(); let tc_name = tc.name.to_string() + &format!(" (hardfork_flags: {:?})", hardfork_flags); @@ -918,13 +717,14 @@ mod tests { scheme: CallScheme::Call, target_address: tc.target_address, bytecode_address: tc.bytecode_address, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: tc.caller, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(tc.calldata.clone()), gas_limit: tc.gas_limit, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let precompile_res = call_native_coin_control(&mut ctx, &inputs, hardfork_flags); @@ -934,17 +734,154 @@ mod tests { } #[test] - fn blocklist_workflow_zero3() { - test_blocklist_workflow(ArcHardforkFlags::default()); + fn delegatecall_penalty_is_reachable_before_success_gas_floor_under_zero8() { + let zero8_flags = ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ArcHardfork::Zero8, + ]); + let zero7_flags = ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ]); + + let cases: &[(&str, Bytes)] = &[ + ( + "blocklist", + INativeCoinControl::blocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + ), + ( + "unBlocklist", + INativeCoinControl::unBlocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + ), + ]; + + for (name, calldata) in cases { + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: ADDRESS_B, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: ALLOWED_CALLER_ADDRESS, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.clone()), + gas_limit: 1_000, + is_static: false, + return_memory_offset: 0..0, + reservoir: 0, + }; + + let mut pre_zero8_ctx = mock_context(baseline_flags()); + let pre_zero8 = call_native_coin_control(&mut pre_zero8_ctx, &inputs, baseline_flags()) + .expect("call should not error") + .expect("result should be Some"); + assert_eq!( + pre_zero8.result, + InstructionResult::PrecompileOOG, + "{name}: pre-Zero7 should hit the success gas floor first", + ); + + let mut zero7_ctx = mock_context(zero7_flags); + let zero7 = call_native_coin_control(&mut zero7_ctx, &inputs, zero7_flags) + .expect("call should not error") + .expect("result should be Some"); + assert_eq!( + zero7.result, + InstructionResult::PrecompileOOG, + "{name}: Zero7 without Zero8 should hit the success gas floor first", + ); + + let mut zero8_ctx = mock_context(zero8_flags); + let zero8 = call_native_coin_control(&mut zero8_ctx, &inputs, zero8_flags) + .expect("call should not error") + .expect("result should be Some"); + assert_eq!( + zero8.result, + InstructionResult::Revert, + "{name}: Zero8 should reach delegatecall validation first", + ); + assert_eq!( + bytes_to_revert_message(zero8.output.as_ref()).as_deref(), + Some(ERR_DELEGATE_CALL_NOT_ALLOWED), + "{name}: wrong revert reason", + ); + assert_eq!( + zero8.gas.used(), + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + "{name}: delegatecall rejection should charge the Zero8 penalty", + ); + } } + #[test] - fn blocklist_workflow_zero4() { - test_blocklist_workflow(ArcHardforkFlags::with(&[ArcHardfork::Zero4])); + fn blocklist_workflow() { + test_blocklist_workflow(baseline_flags()); } + #[test] - fn blocklist_workflow_zero5() { - test_blocklist_workflow(ArcHardforkFlags::with(&[ArcHardfork::Zero5])); + fn blocklist_uses_constant_authority_regardless_of_flags() { + let hardfork_flags = ArcHardforkFlags::default(); + let mut ctx = mock_context(hardfork_flags); + + let blocklist_input = CallInputs { + scheme: CallScheme::Call, + target_address: NATIVE_COIN_CONTROL_ADDRESS, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: ALLOWED_CALLER_ADDRESS, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes( + INativeCoinControl::blocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + ), + gas_limit: 100_000, + is_static: false, + return_memory_offset: 0..0, + reservoir: 0, + }; + + let result = call_native_coin_control(&mut ctx, &blocklist_input, hardfork_flags) + .expect("call should not error") + .expect("result should be Some"); + assert_eq!(result.result, InstructionResult::Return); + assert_eq!(result.output, Bytes::from(true.abi_encode())); + + let is_blocklisted_input = CallInputs { + scheme: CallScheme::Call, + target_address: NATIVE_COIN_CONTROL_ADDRESS, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: ADDRESS_A, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes( + INativeCoinControl::isBlocklistedCall { account: ADDRESS_B } + .abi_encode() + .into(), + ), + gas_limit: 100_000, + is_static: false, + return_memory_offset: 0..0, + reservoir: 0, + }; + + let result = call_native_coin_control(&mut ctx, &is_blocklisted_input, hardfork_flags) + .expect("call should not error") + .expect("result should be Some"); + assert_eq!(result.result, InstructionResult::Return); + assert_eq!(result.output, Bytes::from(true.abi_encode())); } + fn test_blocklist_workflow(hardfork_flags: ArcHardforkFlags) { let mut ctx = mock_context(hardfork_flags); @@ -953,7 +890,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ADDRESS_A, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -964,6 +901,7 @@ mod tests { gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_native_coin_control(&mut ctx, &is_blocklisted_input, hardfork_flags) @@ -977,7 +915,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -988,6 +926,7 @@ mod tests { gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_native_coin_control(&mut ctx, &blocklist_input, hardfork_flags) @@ -1023,7 +962,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -1034,6 +973,7 @@ mod tests { gas_limit: 100_000, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_native_coin_control(&mut ctx, &unblocklist_input, hardfork_flags) @@ -1103,7 +1043,8 @@ mod tests { ), ]; - for hardfork_flags in ArcHardforkFlags::all_combinations() { + { + let hardfork_flags = baseline_flags(); // State-modifying functions must revert under static call for (fn_name, calldata) in state_modifying_calldatas { let mut ctx = mock_context(hardfork_flags); @@ -1111,13 +1052,14 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ALLOWED_CALLER_ADDRESS, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(calldata.clone()), gas_limit: 100_000, is_static: true, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_native_coin_control(&mut ctx, &inputs, hardfork_flags) @@ -1135,6 +1077,13 @@ mod tests { Some(ERR_STATE_CHANGE_DURING_STATIC_CALL), "{fn_name} (hardfork_flags: {hardfork_flags:?}): wrong revert reason", ); + // spend_all() must consume the entire gas limit before reverting, so + // static callers cannot probe stateful precompile paths cheaply. + assert_eq!( + result.gas.used(), + inputs.gas_limit, + "{fn_name} (hardfork_flags: {hardfork_flags:?}): static-call revert must spend all gas", + ); } // Read-only function (isBlocklisted) must succeed under static call @@ -1144,7 +1093,7 @@ mod tests { scheme: CallScheme::Call, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ADDRESS_A, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -1155,6 +1104,7 @@ mod tests { gas_limit: 100_000, is_static: true, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_native_coin_control(&mut ctx, &inputs, hardfork_flags) @@ -1169,4 +1119,62 @@ mod tests { } } } + + #[test] + fn database_errors_abort_native_coin_control_calls() { + use crate::helpers::test_utils::{ + assert_provider_db_error_is_fatal, FailingDB, FailingDbMode, + }; + + let hardfork_flags = ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ]); + let cases: &[(&str, Address, Bytes)] = &[ + ( + "blocklist", + ALLOWED_CALLER_ADDRESS, + INativeCoinControl::blocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + ), + ( + "unBlocklist", + ALLOWED_CALLER_ADDRESS, + INativeCoinControl::unBlocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + ), + ( + "isBlocklisted", + ADDRESS_A, + INativeCoinControl::isBlocklistedCall { account: ADDRESS_B } + .abi_encode() + .into(), + ), + ]; + + for (name, caller, calldata) in cases { + let mut ctx = FailingDB::context(FailingDbMode::Storage); + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: NATIVE_COIN_CONTROL_ADDRESS, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: *caller, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.clone()), + gas_limit: 100_000, + is_static: false, + return_memory_offset: 0..0, + reservoir: 0, + }; + assert_provider_db_error_is_fatal( + call_native_coin_control(&mut ctx, &inputs, hardfork_flags), + name, + ); + } + } } diff --git a/crates/precompiles/src/pq.rs b/crates/precompiles/src/pq.rs index 74200a50..27f67da9 100644 --- a/crates/precompiles/src/pq.rs +++ b/crates/precompiles/src/pq.rs @@ -13,100 +13,25 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -use crate::helpers::{ - record_cost_or_out_of_gas, PrecompileErrorOrRevert, ERR_EXECUTION_REVERTED, - PRECOMPILE_EARLY_REVERT_GAS_PENALTY, -}; -use crate::precompile; -use alloy_primitives::{address, Address}; -use alloy_sol_types::{sol, SolCall, SolValue}; -use reth_ethereum::evm::revm::precompile::PrecompileOutput; -use revm_interpreter::gas::KECCAK256WORD; -use revm_interpreter::Gas; -use slh_dsa::{signature::Verifier, Sha2_128s, Signature, VerifyingKey as SlhDsaVerifyingKey}; - -pub const PQ_ADDRESS: Address = address!("1800000000000000000000000000000000000004"); - -/// Base gas for SLH-DSA-SHA2-128s verification. -/// -/// Conservative relative to the SHA-256 precompile's per-word work anchor. See -/// `crates/precompiles/benches/pq.rs` for the benchmark context comparing this -/// price against SLH-DSA-SHA2-128s verification and 64-byte SHA-256 / KECCAK256 -/// work. -const VERIFY_BASE_GAS: u64 = 230_000; - -/// Dynamic gas cost per 32-byte word of message input. -/// -/// SLH-DSA-SHA2-128s hashes the message once via `H_msg` (SHA-256 + MGF1). -/// This is comparable to KECCAK256, so we use the same per-word rate. -const GAS_PER_MSG_WORD: u64 = KECCAK256WORD; - -sol! { - /// Experimental PQ Signature Verifier precompile interface. - interface IPQ { - /// Verify an SLH-DSA-SHA2-128s signature. - /// - /// Since PQ signatures are still very new, we recommend not to solely - /// rely on them for authentication, but pair them with classical - /// signatures. - /// - /// Gas cost: 230,000 base + 6 per 32-byte word of message (same as KECCAK256) - function verifySlhDsaSha2128s(bytes calldata vk, bytes calldata message, bytes calldata sig) external returns (bool isValid); - } -} -precompile!(run_pq, precompile_input, hardfork_flags; { - IPQ::verifySlhDsaSha2128sCall => |input| { - (|| -> Result { - let _ = hardfork_flags; - let mut gas_counter = Gas::new(precompile_input.gas); - - let args = IPQ::verifySlhDsaSha2128sCall::abi_decode_raw_validate(input).map_err(|_| { - PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, - PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - ERR_EXECUTION_REVERTED, - ) - })?; - - // Charge base gas, then per-word message gas, then validate inputs - record_cost_or_out_of_gas(&mut gas_counter, VERIFY_BASE_GAS)?; - - // GAS_PER_MSG_WORD (6) < 32, so the product cannot exceed u64::MAX - #[allow(clippy::arithmetic_side_effects)] - let msg_word_gas = (args.message.len() as u64).div_ceil(32) * GAS_PER_MSG_WORD; - record_cost_or_out_of_gas(&mut gas_counter, msg_word_gas)?; - - // SLH-DSA-SHA2-128s constants from FIPS 205 - const VK_LEN: usize = 32; - const SIG_LEN: usize = 7856; - - if args.vk.len() != VK_LEN { - return Err(PrecompileErrorOrRevert::new_reverted( - gas_counter, - "Invalid verifying key length", - )); - } +pub use arc_pq_precompile::{IPQ, PQ_ADDRESS}; - if args.sig.len() != SIG_LEN { - return Err(PrecompileErrorOrRevert::new_reverted( - gas_counter, - "Invalid signature length", - )); - } +use arc_execution_config::hardforks::ArcHardforkFlags; +use reth_ethereum::evm::revm::precompile::{PrecompileError, PrecompileOutput}; - let verifying_key = SlhDsaVerifyingKey::::try_from(args.vk.as_ref()) - .map_err(|_| PrecompileErrorOrRevert::new_reverted(gas_counter, "Failed to parse verifying key"))?; - - let signature = Signature::::try_from(args.sig.as_ref()) - .map_err(|_| PrecompileErrorOrRevert::new_reverted(gas_counter, "Failed to parse signature"))?; - - let is_valid = verifying_key.verify(args.message.as_ref(), &signature).is_ok(); +pub(crate) fn run_pq( + input: reth_evm::precompiles::PrecompileInput, + _hardfork_flags: ArcHardforkFlags, +) -> Result { + arc_pq_precompile::run_pq_precompile(input.gas, input.data, input.reservoir) +} - Ok(PrecompileOutput::new(gas_counter.used(), is_valid.abi_encode().into())) - })() - }, -}); +// arc-pq-precompile cannot depend on arc-precompiles (cycle), so this cross-crate equality check +// is placed here to ensure EARLY_REVERT_GAS and PRECOMPILE_EARLY_REVERT_GAS_PENALTY stay in sync. +const _: () = assert!( + arc_pq_precompile::EARLY_REVERT_GAS == crate::helpers::PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + "arc_pq_precompile::EARLY_REVERT_GAS diverged from PRECOMPILE_EARLY_REVERT_GAS_PENALTY" +); #[cfg(test)] mod tests { @@ -210,8 +135,13 @@ mod tests { "Transaction should be successful" ); - IPQ::verifySlhDsaSha2128sCall::abi_decode_returns(exec_result.result.output().unwrap()) - .expect("Should decode return value") + IPQ::verifySlhDsaSha2128sCall::abi_decode_returns( + exec_result + .result + .output() + .expect("transaction result should have output"), + ) + .expect("Should decode return value") } /// Assert transaction completed but failed (revert/halt) @@ -243,7 +173,8 @@ mod tests { let sk_seed = [1u8; 16]; let sk_prf = [2u8; 16]; let pk_seed = [3u8; 16]; - let signing_key = SigningKey::::slh_keygen_internal(&sk_seed, &sk_prf, &pk_seed); + let signing_key = + SigningKey::::slh_keygen_internal(&sk_seed, &sk_prf, &pk_seed); let verifying_key = signing_key.verifying_key(); // Sign a message (note: SLH-DSA sign() is deterministic) @@ -271,7 +202,8 @@ mod tests { let sk_seed = [1u8; 16]; let sk_prf = [2u8; 16]; let pk_seed = [3u8; 16]; - let signing_key = SigningKey::::slh_keygen_internal(&sk_seed, &sk_prf, &pk_seed); + let signing_key = + SigningKey::::slh_keygen_internal(&sk_seed, &sk_prf, &pk_seed); let verifying_key = signing_key.verifying_key(); // Test with 32-byte message @@ -290,7 +222,7 @@ mod tests { // Our precompile cost: 230,000 (base) + 1 word * 6 (message) = 230,006 // Plus EVM calldata cost for large signature (7856 bytes) - let actual_gas = exec_result.result.gas_used(); + let actual_gas = exec_result.result.tx_gas_used(); // SLH-DSA has largest signatures, expect ~370-390K total gas assert!( @@ -316,7 +248,8 @@ mod tests { let sk_seed = [1u8; 16]; let sk_prf = [2u8; 16]; let pk_seed = [3u8; 16]; - let signing_key = SigningKey::::slh_keygen_internal(&sk_seed, &sk_prf, &pk_seed); + let signing_key = + SigningKey::::slh_keygen_internal(&sk_seed, &sk_prf, &pk_seed); let verifying_key = signing_key.verifying_key(); // Sign one message, verify with different message diff --git a/crates/precompiles/src/precompile_provider.rs b/crates/precompiles/src/precompile_provider.rs index 8bcb38d1..0ef69d7a 100644 --- a/crates/precompiles/src/precompile_provider.rs +++ b/crates/precompiles/src/precompile_provider.rs @@ -22,7 +22,7 @@ use crate::pq::{run_pq, PQ_ADDRESS}; use crate::system_accounting::{run_system_accounting, SYSTEM_ACCOUNTING_ADDRESS}; use alloy_evm::precompiles::PrecompilesMap; use alloy_primitives::Address; -use arc_execution_config::hardforks::{ArcHardfork, ArcHardforkFlags}; +use arc_execution_config::hardforks::ArcHardforkFlags; use reth_ethereum::evm::revm::precompile::PrecompileSpecId; use reth_ethereum::evm::revm::precompile::Precompiles; use reth_evm::precompiles::DynPrecompile; @@ -60,16 +60,10 @@ impl ArcPrecompileProvider { PrecompileId::Custom("SYSTEM_ACCOUNTING".into()), move |input| run_system_accounting(input, hardfork_flags), )), - PQ_ADDRESS => { - // Only register PQ precompile if Zero6 hardfork is active - if !hardfork_flags.is_active(ArcHardfork::Zero6) { - return None; - } - Some(DynPrecompile::new_stateful( - PrecompileId::Custom("PQ".into()), - move |input| run_pq(input, hardfork_flags), - )) - } + PQ_ADDRESS => Some(DynPrecompile::new_stateful( + PrecompileId::Custom("PQ".into()), + move |input| run_pq(input, hardfork_flags), + )), _ => handle_unknown_precompile(address), }); precompile_map @@ -123,28 +117,15 @@ mod tests { } #[test] - fn test_pq_precompile_available_with_zero6() { - let precompiles = ArcPrecompileProvider::create_precompiles_map( - SpecId::PRAGUE, - ArcHardforkFlags::with(&[ArcHardfork::Zero6]), - ); - - assert!( - precompiles.get(&PQ_ADDRESS).is_some(), - "PQ precompile should be available when Zero6 is active" - ); - } - - #[test] - fn test_pq_precompile_not_available_without_zero6() { + fn test_pq_precompile_available() { let precompiles = ArcPrecompileProvider::create_precompiles_map( SpecId::PRAGUE, ArcHardforkFlags::default(), ); assert!( - precompiles.get(&PQ_ADDRESS).is_none(), - "PQ precompile should NOT be available without Zero6" + precompiles.get(&PQ_ADDRESS).is_some(), + "PQ precompile should be available" ); } diff --git a/crates/precompiles/src/system_accounting.rs b/crates/precompiles/src/system_accounting.rs index fee3f244..ef0cc99f 100644 --- a/crates/precompiles/src/system_accounting.rs +++ b/crates/precompiles/src/system_accounting.rs @@ -15,17 +15,15 @@ // limitations under the License. use crate::helpers::{ - abi_decode_raw_with_zero6_validation, check_delegatecall, check_staticcall, - new_reverted_with_early_penalty, read, record_cost_or_out_of_gas, write, - PrecompileErrorOrRevert, ERR_EXECUTION_REVERTED, ERR_INVALID_CALLER, - PRECOMPILE_EARLY_REVERT_GAS_PENALTY, PRECOMPILE_SLOAD_GAS_COST, + abi_decode_raw_validated, check_delegatecall, check_staticcall, + new_reverted_with_early_penalty, read, write, PrecompileErrorOrRevert, ERR_EXECUTION_REVERTED, + ERR_INVALID_CALLER, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, }; use crate::precompile; use alloy_evm::Evm; use alloy_primitives::B256; use alloy_primitives::{address, keccak256, Address, Bytes, StorageKey}; use alloy_sol_types::{sol, SolCall, SolValue}; -use arc_execution_config::hardforks::ArcHardfork; use reth_ethereum::evm::revm::precompile::PrecompileOutput; use revm::handler::SYSTEM_ADDRESS; use revm::state::EvmState; @@ -138,6 +136,7 @@ precompile!(run_system_accounting, precompile_input, hardfork_flags; { ISystemAccounting::storeGasValuesCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); + let reservoir = precompile_input.reservoir; let mut precompile_input = precompile_input; // Check if static call is attempting to modify state @@ -147,25 +146,16 @@ precompile!(run_system_accounting, precompile_input, hardfork_flags; { )?; // Decode arguments passed to blocklist function - let args = abi_decode_raw_with_zero6_validation::( - input, - hardfork_flags, - ) + let args = abi_decode_raw_validated::(input) .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, ) )?; - // Redundant 2100-gas charge — no SLOAD occurs here, but kept pre-Zero6 to - // preserve consensus on already-finalized blocks. - if !hardfork_flags.is_active(ArcHardfork::Zero6) { - record_cost_or_out_of_gas(&mut gas_counter, PRECOMPILE_SLOAD_GAS_COST)?; - } - // Check caller if precompile_input.caller != ARC_SYSTEM_CALLER { - return Err(new_reverted_with_early_penalty(gas_counter, ERR_INVALID_CALLER, hardfork_flags)); + return Err(new_reverted_with_early_penalty(gas_counter, reservoir, ERR_INVALID_CALLER)); } // Check delegatecall @@ -173,6 +163,7 @@ precompile!(run_system_accounting, precompile_input, hardfork_flags; { SYSTEM_ACCOUNTING_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Update storage @@ -184,26 +175,24 @@ precompile!(run_system_accounting, precompile_input, hardfork_flags; { storage_slot, &updated_value_bytes, &mut gas_counter, - hardfork_flags, + reservoir, )?; let output = true.abi_encode(); - Ok(PrecompileOutput::new(gas_counter.used(), output.into())) + Ok(PrecompileOutput::new(gas_counter.used(), output.into(), reservoir)) })() }, ISystemAccounting::getGasValuesCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); + let reservoir = precompile_input.reservoir; let mut precompile_input = precompile_input; // Decode arguments passed to blocklist function - let args = abi_decode_raw_with_zero6_validation::( - input, - hardfork_flags, - ) + let args = abi_decode_raw_validated::(input) .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( - gas_counter, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, + gas_counter, reservoir, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED, ) )?; @@ -214,12 +203,12 @@ precompile!(run_system_accounting, precompile_input, hardfork_flags; { SYSTEM_ACCOUNTING_ADDRESS, storage_slot, &mut gas_counter, - hardfork_flags, + reservoir, )?; let gas_values = unpack_gas_values_from_storage(B256::from_slice(slot_value.as_ref())); let output = gas_values.abi_encode(); - Ok(PrecompileOutput::new(gas_counter.used(), output.into())) + Ok(PrecompileOutput::new(gas_counter.used(), output.into(), reservoir)) })() }, }); @@ -357,11 +346,11 @@ mod tests { }; use arc_execution_config::hardforks::{ArcHardfork, ArcHardforkFlags}; - // EIP-2929 warm/cold gas costs for Zero5 + // Baseline EIP-2929 warm/cold gas costs. const WARM_SLOAD_GAS_COST: u64 = 100; // Cold SSTORE (0→non-zero) per EIP-2200 const COLD_SSTORE_ZERO_TO_NONZERO_GAS_COST: u64 = 22100; - use alloy_primitives::{address, Bytes, U256}; + use alloy_primitives::{address, Bytes, B256, U256}; use alloy_sol_types::SolValue; use reth_ethereum::evm::revm::{ context::{Context, ContextTr, JournalTr}, @@ -370,12 +359,22 @@ mod tests { }; use reth_evm::precompiles::{DynPrecompile, PrecompilesMap}; use revm::{ + bytecode::Bytecode, handler::PrecompileProvider, interpreter::InterpreterResult, precompile::{PrecompileId, Precompiles}, }; use serde_with::NoneAsEmptyString; + fn baseline_flags() -> ArcHardforkFlags { + ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ]) + } + fn call_system_accounting( ctx: &mut revm::context::Context< revm::context::BlockEnv, @@ -424,7 +423,7 @@ mod tests { scheme: CallScheme::Call, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ARC_SYSTEM_CALLER, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -438,9 +437,10 @@ mod tests { gas_limit, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; - call_system_accounting(ctx, &inputs, ArcHardforkFlags::with(&[ArcHardfork::Zero5])) + call_system_accounting(ctx, &inputs, baseline_flags()) .unwrap() .unwrap() } @@ -454,7 +454,7 @@ mod tests { scheme: CallScheme::Call, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ARC_SYSTEM_CALLER, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -467,12 +467,12 @@ mod tests { gas_limit, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; - let res = - call_system_accounting(ctx, &inputs, ArcHardforkFlags::with(&[ArcHardfork::Zero5])) - .unwrap() - .unwrap(); + let res = call_system_accounting(ctx, &inputs, baseline_flags()) + .unwrap() + .unwrap(); let decoded = ISystemAccounting::getGasValuesCall::abi_decode_returns(res.output.as_ref()) .expect("decode getGasValues"); (res, decoded) @@ -595,22 +595,19 @@ mod tests { scheme: CallScheme::Call, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: tc.caller, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(tc.calldata.clone()), gas_limit: tc.gas_limit, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; - let res = call_system_accounting( - &mut ctx, - &inputs, - ArcHardforkFlags::with(&[ArcHardfork::Zero5]), - ) - .unwrap() - .unwrap(); + let res = call_system_accounting(&mut ctx, &inputs, baseline_flags()) + .unwrap() + .unwrap(); // Result assert_eq!(res.result, tc.expected_result, "{}", tc.name); @@ -626,8 +623,102 @@ mod tests { assert_eq!(res.output, *expected_return, "{}", tc.name); } - // Gas used - assert_eq!(res.gas.used(), tc.gas_used, "{}", tc.name); + // Gas used. Skip on PrecompileOOG: under revm 38 the precompile-result + // converter always `spend_all()`s on Halt, so `res.gas.used()` is + // tautologically the gas_limit. + if tc.expected_result != InstructionResult::PrecompileOOG { + assert_eq!(res.gas.used(), tc.gas_used, "{}", tc.name); + } + } + } + + struct StoreCase { + name: &'static str, + caller: Address, + calldata: Bytes, + gas_limit: u64, + /// If set, overrides `gas_limit` when Zero6 is active. Needed when + /// the Zero6 early-revert penalty pushes required gas above the + /// Zero5 limit. + zero6_gas_limit: Option, + expected_result: InstructionResult, + expected_revert_str: Option<&'static str>, + return_data: Option, + gas_used: u64, + /// If set, overrides `gas_used` for pre-Zero5 hardforks (fixed + /// SSTORE cost vs. EIP-2929/EIP-2200 warm/cold pricing). + pre_zero5_gas_used: Option, + /// If set, overrides `gas_used` when Zero6 is active (auth reverts + /// charge `PRECOMPILE_EARLY_REVERT_GAS_PENALTY`). + zero6_gas_used: Option, + target_address: Address, + bytecode_address: Address, + } + + impl StoreCase { + fn gas_limit_for(&self, flags: ArcHardforkFlags) -> u64 { + if flags.is_active(ArcHardfork::Zero6) { + self.zero6_gas_limit.unwrap_or(self.gas_limit) + } else { + self.gas_limit + } + } + + fn expected_gas_used_for(&self, flags: ArcHardforkFlags) -> u64 { + if flags.is_active(ArcHardfork::Zero6) { + self.zero6_gas_used.unwrap_or(self.gas_used) + } else if flags.is_active(ArcHardfork::Zero5) { + self.gas_used + } else { + self.pre_zero5_gas_used.unwrap_or(self.gas_used) + } + } + } + + fn run_store_case(tc: &StoreCase, hardfork_flags: ArcHardforkFlags) { + let tc_name = format!("{} (hardfork_flags: {hardfork_flags:?})", tc.name); + + let mut ctx = Context::mainnet(); + ctx.journal_mut() + .load_account(SYSTEM_ACCOUNTING_ADDRESS) + .expect("Unable to load system accounting account"); + + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: tc.target_address, + bytecode_address: tc.bytecode_address, + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: tc.caller, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(tc.calldata.clone()), + gas_limit: tc.gas_limit_for(hardfork_flags), + is_static: false, + return_memory_offset: 0..0, + reservoir: 0, + }; + + let res = call_system_accounting(&mut ctx, &inputs, hardfork_flags) + .unwrap() + .unwrap(); + + assert_eq!(res.result, tc.expected_result, "{tc_name}"); + + if let Some(expected_revert_str) = tc.expected_revert_str { + let reason = bytes_to_revert_message(res.output.as_ref()).expect("revert reason"); + assert_eq!(reason, expected_revert_str, "{tc_name}"); + } + + if let Some(expected_return) = &tc.return_data { + assert_eq!(res.output, *expected_return, "{tc_name}"); + } + + // Skip on PrecompileOOG (see comment in failure-case table test). + if tc.expected_result != InstructionResult::PrecompileOOG { + assert_eq!( + res.gas.used(), + tc.expected_gas_used_for(hardfork_flags), + "{tc_name}" + ); } } @@ -638,20 +729,10 @@ mod tests { caller: Address, calldata: Bytes, gas_limit: u64, - /// If set, overrides `gas_limit` when Zero6 is active. Needed when - /// the Zero6 early-revert penalty pushes required gas above the - /// Zero5 limit. - zero6_gas_limit: Option, expected_result: InstructionResult, expected_revert_str: Option<&'static str>, return_data: Option, gas_used: u64, - /// If set, overrides `gas_used` for pre-Zero5 hardforks (fixed - /// SSTORE cost vs. EIP-2929/EIP-2200 warm/cold pricing). - pre_zero5_gas_used: Option, - /// If set, overrides `gas_used` when Zero6 is active (auth reverts - /// charge `PRECOMPILE_EARLY_REVERT_GAS_PENALTY`). - zero6_gas_used: Option, target_address: Address, bytecode_address: Address, } @@ -662,11 +743,7 @@ mod tests { gasUsedSmoothed: 22, nextBaseFee: 33, }; - // Zero5: 2100 (redundant pre-auth charge, no real SLOAD) + 22100 (cold SSTORE 0→non-zero) - // = 24200. - // Zero6: 22100 only — redundant charge dropped (see `zero6_gas_used` override below). - // Pre-Zero5 uses the fixed SSTORE path (see `pre_zero5_gas_used` override below). - let expected_gas_success = PRECOMPILE_SLOAD_GAS_COST + COLD_SSTORE_ZERO_TO_NONZERO_GAS_COST; + let expected_gas_success = COLD_SSTORE_ZERO_TO_NONZERO_GAS_COST; let cases: &[StoreCase] = &[ StoreCase { @@ -679,13 +756,10 @@ mod tests { .abi_encode() .into(), gas_limit: expected_gas_success, - zero6_gas_limit: None, expected_result: InstructionResult::Return, expected_revert_str: None, return_data: Some(true.abi_encode().into()), - gas_used: expected_gas_success, - pre_zero5_gas_used: Some(PRECOMPILE_SLOAD_GAS_COST + PRECOMPILE_SSTORE_GAS_COST), - zero6_gas_used: Some(COLD_SSTORE_ZERO_TO_NONZERO_GAS_COST), + gas_used: COLD_SSTORE_ZERO_TO_NONZERO_GAS_COST, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, }, @@ -694,13 +768,10 @@ mod tests { caller: ARC_SYSTEM_CALLER, calldata: ISystemAccounting::storeGasValuesCall::SELECTOR.into(), gas_limit: PRECOMPILE_SLOAD_GAS_COST, - zero6_gas_limit: None, expected_result: InstructionResult::Revert, expected_revert_str: Some(ERR_EXECUTION_REVERTED), return_data: None, gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, - pre_zero5_gas_used: None, - zero6_gas_used: None, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, }, @@ -713,17 +784,12 @@ mod tests { } .abi_encode() .into(), - // Pre-Zero6: OOGs at the redundant 2100-gas pre-auth charge. - gas_limit: PRECOMPILE_SLOAD_GAS_COST - 1, - // Zero6: redundant charge dropped, so the next gas-charging point is the - // cold SSTORE inside `write()`. One gas short of that cost OOGs there. - zero6_gas_limit: Some(COLD_SSTORE_ZERO_TO_NONZERO_GAS_COST - 1), + // One gas short of the cold SSTORE cost OOGs while storing. + gas_limit: COLD_SSTORE_ZERO_TO_NONZERO_GAS_COST - 1, expected_result: InstructionResult::PrecompileOOG, expected_revert_str: None, return_data: None, gas_used: 0, - pre_zero5_gas_used: None, - zero6_gas_used: None, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, }, @@ -736,21 +802,16 @@ mod tests { } .abi_encode() .into(), - gas_limit: PRECOMPILE_SLOAD_GAS_COST, - // Zero6: redundant 2100-gas charge dropped, so only the early-revert - // penalty is consumed. Limit must still cover the penalty exactly. - zero6_gas_limit: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_limit: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, expected_result: InstructionResult::Revert, expected_revert_str: Some(ERR_INVALID_CALLER), return_data: None, - gas_used: PRECOMPILE_SLOAD_GAS_COST, - pre_zero5_gas_used: None, - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, }, StoreCase { - name: "reverts from zero-address caller (legacy system caller)", + name: "reverts from zero-address caller", caller: Address::ZERO, calldata: ISystemAccounting::storeGasValuesCall { blockNumber: bn_ok, @@ -758,14 +819,11 @@ mod tests { } .abi_encode() .into(), - gas_limit: PRECOMPILE_SLOAD_GAS_COST, - zero6_gas_limit: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_limit: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, expected_result: InstructionResult::Revert, expected_revert_str: Some(ERR_INVALID_CALLER), return_data: None, - gas_used: PRECOMPILE_SLOAD_GAS_COST, - pre_zero5_gas_used: None, - zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), + gas_used: PRECOMPILE_EARLY_REVERT_GAS_PENALTY, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, }, @@ -779,16 +837,10 @@ mod tests { .abi_encode() .into(), gas_limit: expected_gas_success, - zero6_gas_limit: None, expected_result: InstructionResult::Revert, expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), return_data: None, - gas_used: PRECOMPILE_SLOAD_GAS_COST, - pre_zero5_gas_used: None, - // Zero6: nothing is charged before check_delegatecall reverts (auth passes, - // redundant pre-auth charge dropped). System-tx callers never delegatecall in - // production, so the 0-gas exit here is unreachable on real workloads. - zero6_gas_used: Some(0), + gas_used: 0, target_address: address!("0x0000000000000000000000000000000000000123"), bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, }, @@ -802,43 +854,20 @@ mod tests { .abi_encode() .into(), gas_limit: expected_gas_success, - zero6_gas_limit: None, expected_result: InstructionResult::Revert, expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), return_data: None, - gas_used: PRECOMPILE_SLOAD_GAS_COST, - pre_zero5_gas_used: None, - zero6_gas_used: Some(0), + gas_used: 0, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: address!("0x0000000000000000000000000000000000000123"), }, ]; for tc in cases { - for hardfork_flags in ArcHardforkFlags::all_combinations() { - // ZeroX hardforks are cumulative; Zero6 implies Zero5. - if hardfork_flags.is_active(ArcHardfork::Zero6) - && !hardfork_flags.is_active(ArcHardfork::Zero5) - { - continue; - } - + { + let hardfork_flags = baseline_flags(); let tc_name = format!("{} (hardfork_flags: {:?})", tc.name, hardfork_flags); - let gas_limit = if hardfork_flags.is_active(ArcHardfork::Zero6) { - tc.zero6_gas_limit.unwrap_or(tc.gas_limit) - } else { - tc.gas_limit - }; - - let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero6) { - tc.zero6_gas_used.unwrap_or(tc.gas_used) - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - tc.gas_used - } else { - tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) - }; - let mut ctx = Context::mainnet(); ctx.journal_mut() .load_account(SYSTEM_ACCOUNTING_ADDRESS) @@ -848,13 +877,14 @@ mod tests { scheme: CallScheme::Call, target_address: tc.target_address, bytecode_address: tc.bytecode_address, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: tc.caller, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(tc.calldata.clone()), - gas_limit, + gas_limit: tc.gas_limit, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; let res = call_system_accounting(&mut ctx, &inputs, hardfork_flags) @@ -874,12 +904,89 @@ mod tests { if let Some(expected_return) = &tc.return_data { assert_eq!(res.output, *expected_return, "{tc_name}"); } - // Gas used - assert_eq!(res.gas.used(), expected_gas_used, "{tc_name}"); + // Gas used (skip on PrecompileOOG: revm 38 spend_all()s on Halt, + // so used() is tautologically the gas limit). + if tc.expected_result != InstructionResult::PrecompileOOG { + assert_eq!(res.gas.used(), tc.gas_used, "{tc_name}"); + } } } } + #[test] + fn delegatecall_charges_early_revert_penalty_only_under_zero8() { + let calldata = ISystemAccounting::storeGasValuesCall { + blockNumber: 1, + gasValues: GasValues { + gasUsed: 1, + gasUsedSmoothed: 2, + nextBaseFee: 3, + }, + } + .abi_encode(); + + let gas_used_for = |hardfork_flags: ArcHardforkFlags| -> u64 { + let mut ctx = Context::mainnet(); + ctx.journal_mut() + .load_account(SYSTEM_ACCOUNTING_ADDRESS) + .expect("load system accounting account"); + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: SYSTEM_ACCOUNTING_ADDRESS, + bytecode_address: address!("0x0000000000000000000000000000000000000123"), + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: ARC_SYSTEM_CALLER, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.clone().into()), + gas_limit: 1_000_000, + is_static: false, + return_memory_offset: 0..0, + reservoir: 0, + }; + let res = call_system_accounting(&mut ctx, &inputs, hardfork_flags) + .unwrap() + .unwrap(); + assert_eq!(res.result, InstructionResult::Revert); + assert_eq!( + bytes_to_revert_message(res.output.as_ref()).expect("revert reason"), + ERR_DELEGATE_CALL_NOT_ALLOWED, + ); + res.gas.used() + }; + + let zero8_flags = ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ArcHardfork::Zero8, + ]); + let zero7_flags = ArcHardforkFlags::with(&[ + ArcHardfork::Zero3, + ArcHardfork::Zero4, + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ]); + + assert_eq!( + gas_used_for(baseline_flags()), + 0, + "pre-Zero7 delegatecall rejection charges no penalty", + ); + assert_eq!( + gas_used_for(zero7_flags), + 0, + "Zero7 without Zero8 delegatecall rejection charges no penalty", + ); + assert_eq!( + gas_used_for(zero8_flags), + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + "Zero8 delegatecall rejection charges the early-revert penalty", + ); + } + #[test] fn read_write_workflow() { let mut ctx = Context::mainnet(); @@ -940,15 +1047,15 @@ mod tests { assert_eq!(decoded_read_original_block.gasUsedSmoothed, 5); } - /// Under Zero6+, any SSTORE through `helpers::write` must fail with + /// With the baseline rules, any SSTORE through `helpers::write` must fail with /// `PrecompileOOG` and consume zero gas when the remaining gas is at or /// below `CALL_STIPEND` (2,300), mirroring revm's `ReentrancySentryOOG` /// halt for the SSTORE opcode. #[test] - fn store_gas_values_eip_2200_sentry_zero6() { + fn store_gas_values_eip_2200_sentry_baseline() { use revm_context_interface::cfg::gas::CALL_STIPEND; - let zero6_flags = ArcHardforkFlags::with(&[ArcHardfork::Zero5, ArcHardfork::Zero6]); + let hardfork_flags = baseline_flags(); let calldata: Bytes = ISystemAccounting::storeGasValuesCall { blockNumber: 1, @@ -965,13 +1072,14 @@ mod tests { scheme: CallScheme::Call, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ARC_SYSTEM_CALLER, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(calldata.clone()), gas_limit, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; // gas_limit == CALL_STIPEND: sentry fires immediately; no auth check, @@ -982,19 +1090,21 @@ mod tests { .load_account(SYSTEM_ACCOUNTING_ADDRESS) .expect("load system accounting account"); - let res = call_system_accounting(&mut ctx, &make_inputs(gas_limit), zero6_flags) + let res = call_system_accounting(&mut ctx, &make_inputs(gas_limit), hardfork_flags) .unwrap() .unwrap(); assert_eq!( res.result, InstructionResult::PrecompileOOG, - "Zero6 sentry must OOG at gas_limit={gas_limit}" + "baseline sentry must OOG at gas_limit={gas_limit}" ); + // revm 38: PrecompileOOG drains all gas at the converter, so + // `res.gas.used()` is always `gas_limit` here. assert_eq!( res.gas.used(), - 0, - "Zero6 sentry must charge zero gas at gas_limit={gas_limit}" + gas_limit, + "baseline sentry OOG must drain all gas at gas_limit={gas_limit}" ); } @@ -1005,7 +1115,7 @@ mod tests { ctx.journal_mut() .load_account(SYSTEM_ACCOUNTING_ADDRESS) .expect("load system accounting account"); - let res = call_system_accounting(&mut ctx, &make_inputs(CALL_STIPEND + 1), zero6_flags) + let res = call_system_accounting(&mut ctx, &make_inputs(CALL_STIPEND + 1), hardfork_flags) .unwrap() .unwrap(); assert_eq!(res.result, InstructionResult::PrecompileOOG); @@ -1017,7 +1127,7 @@ mod tests { ctx.journal_mut() .load_account(SYSTEM_ACCOUNTING_ADDRESS) .expect("load system accounting account"); - let res = call_system_accounting(&mut ctx, &make_inputs(happy_gas), zero6_flags) + let res = call_system_accounting(&mut ctx, &make_inputs(happy_gas), hardfork_flags) .unwrap() .unwrap(); assert_eq!(res.result, InstructionResult::Return); @@ -1048,13 +1158,14 @@ mod tests { scheme: CallScheme::Call, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ARC_SYSTEM_CALLER, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(calldata.clone()), gas_limit, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; // Gas = COLD_SLOAD_COST - 1: must OOG at the cold charge, zero DB reads. @@ -1131,13 +1242,14 @@ mod tests { scheme: CallScheme::Call, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ARC_SYSTEM_CALLER, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes(calldata.clone()), gas_limit, is_static: false, return_memory_offset: 0..0, + reservoir: 0, }; // Gas = COLD_SLOAD_COST - 1: OOG at the cold charge, before the sload DB read. @@ -1309,7 +1421,8 @@ mod tests { fn test_static_call_reverts_store_gas_values() { use crate::helpers::ERR_STATE_CHANGE_DURING_STATIC_CALL; - for hardfork_flags in ArcHardforkFlags::all_combinations() { + { + let hardfork_flags = baseline_flags(); let mut ctx = Context::mainnet(); ctx.journal_mut() .load_account(SYSTEM_ACCOUNTING_ADDRESS) @@ -1320,7 +1433,7 @@ mod tests { scheme: CallScheme::Call, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ARC_SYSTEM_CALLER, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -1338,6 +1451,7 @@ mod tests { gas_limit: 100_000, is_static: true, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_system_accounting(&mut ctx, &inputs, hardfork_flags) @@ -1355,13 +1469,20 @@ mod tests { Some(ERR_STATE_CHANGE_DURING_STATIC_CALL), "storeGasValues ({hardfork_flags:?}): wrong revert reason", ); + // spend_all() must consume the entire gas limit before reverting, so + // static callers cannot probe stateful precompile paths cheaply. + assert_eq!( + result.gas.used(), + inputs.gas_limit, + "storeGasValues ({hardfork_flags:?}): static-call revert must spend all gas", + ); // Read-only function (getGasValues) must succeed under static call let read_inputs = CallInputs { scheme: CallScheme::Call, target_address: SYSTEM_ACCOUNTING_ADDRESS, bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, - known_bytecode: None, + known_bytecode: (B256::ZERO, Bytecode::default()), caller: ARC_SYSTEM_CALLER, value: CallValue::Transfer(U256::ZERO), input: CallInput::Bytes( @@ -1372,6 +1493,7 @@ mod tests { gas_limit: 100_000, is_static: true, return_memory_offset: 0..0, + reservoir: 0, }; let result = call_system_accounting(&mut ctx, &read_inputs, hardfork_flags) @@ -1385,4 +1507,57 @@ mod tests { ); } } + + #[test] + fn database_errors_abort_system_accounting_calls() { + use crate::helpers::test_utils::{ + assert_provider_db_error_is_fatal, FailingDB, FailingDbMode, + }; + + let hardfork_flags = baseline_flags(); + let cases: &[(&str, Address, Bytes)] = &[ + ( + "storeGasValues", + ARC_SYSTEM_CALLER, + ISystemAccounting::storeGasValuesCall { + blockNumber: 1, + gasValues: GasValues { + gasUsed: 1, + gasUsedSmoothed: 1, + nextBaseFee: 1, + }, + } + .abi_encode() + .into(), + ), + ( + "getGasValues", + ARC_SYSTEM_CALLER, + ISystemAccounting::getGasValuesCall { blockNumber: 1 } + .abi_encode() + .into(), + ), + ]; + + for (name, caller, calldata) in cases { + let mut ctx = FailingDB::context(FailingDbMode::Storage); + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: SYSTEM_ACCOUNTING_ADDRESS, + bytecode_address: SYSTEM_ACCOUNTING_ADDRESS, + known_bytecode: (B256::ZERO, Bytecode::default()), + caller: *caller, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.clone()), + gas_limit: 100_000, + is_static: false, + return_memory_offset: 0..0, + reservoir: 0, + }; + assert_provider_db_error_is_fatal( + call_system_accounting(&mut ctx, &inputs, hardfork_flags), + name, + ); + } + } } diff --git a/crates/quake/Cargo.toml b/crates/quake/Cargo.toml index a855458d..03b484c1 100644 --- a/crates/quake/Cargo.toml +++ b/crates/quake/Cargo.toml @@ -11,6 +11,10 @@ publish.workspace = true name = "quake" path = "src/main.rs" +[features] +default = ["byzantine"] +byzantine = ["arc-consensus-types/byzantine", "arc-node-consensus/byzantine"] + [dependencies] alloy-consensus = { workspace = true } @@ -63,6 +67,7 @@ serde_json = { workspace = true } spammer = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } +tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } toml = { workspace = true, features = ["preserve_order"] } @@ -72,7 +77,6 @@ url = { workspace = true, features = ["serde"] } [dev-dependencies] bytesize = { workspace = true } -tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/quake/README.md b/crates/quake/README.md index 0ede32f1..1ea68833 100644 --- a/crates/quake/README.md +++ b/crates/quake/README.md @@ -3,7 +3,7 @@ Quake is a tool for deploying Arc testnets and running end-to-end tests. > **Quake testnets vs. the Arc Testnet:** Arc has a public, persistent -> [Testnet](https://docs.arc.io/arc/tutorials/deploy-on-arc) open to +> [Testnet](https://docs.arc.network/arc/tutorials/deploy-on-arc) open to > external developers and validators. Quake testnets are different: they are > private, ephemeral networks spun up on demand for development and CI, then > torn down when testing is complete. All mentions of "testnet" in this @@ -38,6 +38,7 @@ __Table of contents__ - [Upgrade](#upgrade) - [Chaos testing](#chaos-testing) - [The `valset` command](#the-valset-command) + - [The `rpc` command](#the-rpc-command) - [The `web` command](#the-web-command) - [The `mcp` command](#the-mcp-command) - [The `generate` command](#the-generate-command) @@ -50,8 +51,10 @@ __Table of contents__ - [Default Flags](#default-flags) - [Reserved Flags](#reserved-flags-do-not-override) - [Examples](#examples) + - [Environment Variables](#environment-variables) - [Voting Power](#voting-power) - [Node Groups and Persistent Peers](#node-groups-and-persistent-peers) + - [Per-node and per-group images](#per-node-and-per-group-images) - [Starting height](#starting-height) - [Subnets](#subnets) - [Latency emulation](#latency-emulation) @@ -423,6 +426,102 @@ full details on the tracking architecture, CSV output format, and analysis tools Transactions that are never included in a block (dropped from mempool, rejected after submission) do not appear in the CSV. +### The `run saturation` command + +`quake run saturation` orchestrates a multi-phase load experiment that ramps +offered TPS across a configured rate list and decides when the cluster hits +saturation. Each phase: warms the spammer state, runs the spammer at the +target rate for a fixed window, drains the mempool, snapshots a wide set of +Prometheus + RPC metrics, and appends a `PhaseRecord` to `experiment.json`. +Six saturation signals (gas plateau, TPS plateau, TPS-ratio drop, latency +spike, mempool growth, EL-CPU saturation) are evaluated between adjacent +phases; the first phase where any signal fires is reported as the saturation +point. + +The testnet must already be set up and running — the runner never starts or +stops the cluster, only drives load against it. It works for both local +(Docker Compose) and remote (AWS EC2) testnets transparently. + +```bash +# Canonical saturation sweep against the non-validator submission tier. +# Ramps 1000 → 2000 TPS in 100-TPS increments, 3 min per phase, with 30 s +# warmup at the first rate and 30 s mempool drain between phases. +./quake run saturation \ + --targets ALL_NON_VALIDATORS \ + --rampup 30s --cooldown 30s --phase-duration 3m \ + --rates 1000-2000:100 + +# Same against a remote testnet. +./quake -f crates/quake/scenarios/examples/27nodes-saturation.toml \ + run saturation --targets ALL_NON_VALIDATORS --rates 1000-2000:100 + +# Discrete rate list instead of a range. +./quake run saturation --targets ALL_NON_VALIDATORS --rates 500,1000,2000,4000 +``` + +`--rates` accepts either a comma-separated list of single rates (`500,1000,4000`) +or one or more inclusive ranges with a step (`1000-2000:100` → 1000, 1100, …, +2000). The parser rejects degenerate inputs (`step > range`, `start == end`, +non-evenly-dividing step, expansion above 256 phases) so an operator typo +fails fast instead of producing a year-long run. + +Common flags (see `./quake run saturation --help` for the full list): + +| Flag | Default | Description | +|------|---------|-------------| +| `--rates` | `500,1000,2000,4000` | Offered-TPS sweep (singles, ranges, or a mix) | +| `--rampup` | `90s` | Warmup phase duration at the first rate | +| `--phase-duration` / `-d` | `5m` | Measured window per rate | +| `--cooldown` | `90s` | Mempool drain between phases | +| `--max-duration` | `3h` | Wall-clock hard limit | +| `--generators` | `10` | Parallel spammer generators | +| `--targets` | every node | Spammer submission targets (names or manifest groups) | +| `--mix` | `transfer=35,legacy=25,erc20=25,guzzler=15` | Tx type blend | +| `--guzzler-fn-weights` | `hash-loop=77@200,storage-write=3@1,storage-read=20@35` | Per-function weights + args for guzzler calls | +| `--erc20-fn-weights` | `transfer=100` | Per-function weights for ERC-20 calls | +| `--tx-input-size` | `0` | Extra random bytes appended to each transaction's input field | +| `--output-dir` | `.quake/experiments` | Where `/` is written | + +Each run produces an artifact directory like +`.quake/experiments/saturation-20260626T140000Z/` containing: + +- `experiment.json` — full metadata, parameters, the manifest used, and every + phase's metric snapshot +- `phase_/tx_latency_*.csv` — per-tx submit-to-finalized latencies +- `metrics.tar.gz` — Prometheus snapshot for the experiment window (remote + testnets only) + +The HTML report scripts in [`scripts/`](../../scripts/) (`saturation_report.py` +and `compare_saturation.py`) consume `experiment.json` to render +self-contained charts and per-rate delta tables; see the "Saturation reports" +section below for their invocation. + +### Saturation reports + +Two Python scripts in [`scripts/`](../../scripts/) render self-contained HTML +reports from a saturation experiment's `experiment.json`. Both require +`pip install matplotlib jinja2`. + +```bash +# Single-experiment report: per-phase charts (TPS, gas/s, latency, CPU, +# memory, mempool subpools), saturation-point callout, topology summary, +# and a full manifest dump for reproducibility. +python3 scripts/saturation_report.py .quake/experiments/saturation-20260626T140000Z + +# Side-by-side comparison of two experiments (e.g. baseline vs. a config +# change). Writes a single HTML file with paired charts and a per-rate +# row-by-row delta table. +python3 scripts/compare_saturation.py \ + .quake/experiments/saturation-20260626T140000Z-baseline \ + .quake/experiments/saturation-20260626T140000Z-candidate \ + /tmp/compare.html \ + --label-a baseline --label-b candidate +``` + +The reports are entirely self-contained — charts are inlined as base64 PNGs +and the manifest is embedded — so they can be attached to a Jira ticket or +emailed without external assets. + ### The `perturb` command Quake offers a number of perturbations that can be applied to nodes in the testnet. These are available as subcommands of the `perturb` command: @@ -576,8 +675,11 @@ from the deployment YAML files will be used (namely, `arc_consensus:latest` and The `image_cl_upgrade` and `image_el_upgrade` fields specify which Docker image to use when upgrading nodes with the `upgrade` command. -These are global settings that apply to all nodes, so you only need to declare them -once. At the moment we don't support per-node tags. +`image_cl_upgrade`/`image_el_upgrade` are global: `upgrade` switches every +targeted node to the same upgrade image. The base `image_cl`/`image_el`, by +contrast, can be overridden per node or per node group to boot a mixed-version +network from genesis; see +[Per-node and per-group images](#per-node-and-per-group-images). Usage examples: ``` @@ -621,6 +723,107 @@ Notes: do not use container names (`validator1_cl`, `validator2_cl`). - it does not accept `*` wildcards. +### The `rpc` command + +The `rpc` command fans an RPC request out to one or more nodes in parallel and +prints each node's result. It targets two different protocols: + +| Subcommand | Layer | Protocol | Server | +|------------|-------|----------|--------| +| `quake rpc el` | Execution Layer | JSON-RPC over HTTP | Reth | +| `quake rpc cl` | Consensus Layer | REST over HTTP | Malachite | +| `quake rpc list` | both | shows CL endpoint catalog + Reth docs link | — | + +Requests run concurrently against every selected node. The process exits 0 only +when every node succeeded; per-node errors are reported in the output without +aborting the fan-out. + +#### `quake rpc el` — Execution Layer JSON-RPC + +``` +quake rpc el [TARGET] [PARAMS...] [--raw ''] + [--timeout SECS] [--retries N] [--format json|table|raw] +``` + +- `` is the JSON-RPC method, e.g. `admin_clearTxpool`, `eth_blockNumber`. +- `[TARGET]` is a comma-separated list of node names or manifest groups + (`ALL_NODES`, `ALL_VALIDATORS`, `ALL_NON_VALIDATORS`, or any custom group). + When omitted, defaults to **all manifest nodes**. +- `[PARAMS...]` are positional JSON-RPC params. Each one is auto-promoted via + `serde_json::from_str`: `42` becomes a number, `true` a boolean, + `[1,2,3]` an array, `{"a":1}` an object; anything that doesn't parse stays a + JSON string (so `0xabc` and `latest` pass through unchanged). +- `--raw ''` lets you supply the params as a literal JSON array (mutually + exclusive with positional params, for cases where auto-promotion is awkward). + +> [!IMPORTANT] +> When you want to pass params alongside the default `ALL_NODES` target, write +> the target slot explicitly: `quake rpc el eth_getBalance ALL_NODES 0xabc latest`. +> The first positional after the method is always the target. + +Examples: +```bash +# Wipe every node's mempool (the original use case) +quake rpc el admin_clearTxpool + +# Read the latest block number from every validator +quake rpc el eth_blockNumber ALL_VALIDATORS + +# Single-node read with raw output suitable for piping +quake rpc el eth_blockNumber validator1 --format raw + +# Account balance lookup against all nodes +quake rpc el eth_getBalance ALL_NODES 0xaaaa...bbbb latest + +# Complex params via --raw +quake rpc el eth_call validator1 --raw '[{"to":"0x...","data":"0x..."},"latest"]' +``` + +Reth's full JSON-RPC reference is at . + +#### `quake rpc cl` — Consensus Layer REST + +``` +quake rpc cl [TARGET] [--method GET|POST|DELETE|PUT|PATCH] + [--body ''] [--timeout SECS] [--retries N] + [--format json|table|raw] +``` + +- `` is the REST path. The leading `/` is optional and prepended + automatically (so `consensus-state` and `/consensus-state` are equivalent). + May include a query string (e.g. `'/commit?height=42'`). +- `[TARGET]` mirrors the EL form; defaults to all consensus-enabled nodes. +- `--method` defaults to `GET`. `--body` supplies a JSON body for mutating + verbs. + +Examples: +```bash +# Catalog of every CL endpoint (live, fetched from a running node) +quake rpc list + +# Application status on every validator (leading slash optional) +quake rpc cl /status ALL_VALIDATORS +quake rpc cl status ALL_VALIDATORS + +# Latest commit certificate from one validator +quake rpc cl /commit validator1 + +# Commit at a specific height +quake rpc cl '/commit?height=42' validator1 + +# Add a persistent peer +quake rpc cl /persistent-peers validator1 \ + --method POST --body '{"addr":"/ip4/.../tcp/26656/p2p/12D3KooW..."}' +``` + +#### Output formats + +| `--format` | When to use | +|------------|-------------| +| `json` (default) | Newline-delimited JSON: `{"node":"validator1","result":...}` per line. Pipe to `jq`. | +| `table` | Two-column `NODE | RESULT` table sorted by node name. Best for ad-hoc inspection. | +| `raw` | Prints the result value only (no `node`/`result` envelope). Requires a single target node. | + ### The `web` command The `web` command starts a browser-based topology viewer that visualizes the testnet in real time and allows you to control the testnet. @@ -675,19 +878,13 @@ The server automatically discovers the most recently used testnet via `.quake/.l > Run the probe tests ``` -#### Transport modes - -The MCP server supports two transport modes: +#### Transport -- **stdio** (default): The server communicates over stdin/stdout. This is the standard mode used by Claude Code, Cursor, and similar clients that spawn the server as a subprocess. - ```bash - quake mcp - ``` +The MCP server uses stdio transport. This is the standard mode used by Claude Code, Cursor, and similar clients that spawn the server as a subprocess. -- **HTTP+SSE** (`--http`): The server listens on a network port for remote MCP clients. Useful when the testnet is running on a remote machine. - ```bash - quake mcp --http --port 8080 - ``` +```bash +quake mcp +``` #### Available tools @@ -823,8 +1020,43 @@ Monitoring services (Prometheus, Grafana, cAdvisor, Blockscout) can be controlle # Stop monitoring services and remove monitoring data ./quake monitoring clean + +# Download a Prometheus metrics snapshot + a single-node database snapshot +./quake download + +# Or download just one: +./quake download metrics +./quake download db +``` + +The `download` subcommand group queries the running Prometheus directly over +HTTP (local Docker port for local testnets, SSM-tunnelled port for remote) and +bundles each metric's `query_range` response into one archive. The `db` +subcommand archives node database files (remote) or logs their on-disk paths +(local). Common options: + +```bash +# Limit to a time range +./quake download metrics --from 2024-01-15T10:30:00Z --to 2024-01-15T12:00:00Z + +# Download only specific metrics (names go after `--`) +./quake download metrics -- reth_db_size_bytes go_goroutines + +# Save to a custom path +./quake download metrics -o /tmp/my-metrics.tar.gz + +# Download db from a specific node (default: first node in manifest) +./quake download db -- validator1 ``` +Without `--from`, the start defaults to Prometheus' `headStats.minTime` (the +current head block start, typically the last ~2 h). Without `--to`, defaults +to now. Without `--step`, the step is auto-sized to keep the response below +Prometheus' 11 000-point limit. Archives land in +`.quake/metrics//` and `.quake/db//` by default; pass `-o` +to override. `quake remote download {metrics,db}` is deprecated but kept as a +backward-compatible alias. + ## Manifest File Format The manifest is a TOML file. @@ -847,7 +1079,9 @@ Optional top-level settings: - for remote mode: `${IMAGE_REGISTRY_URL}/arc-consensus:` and `${IMAGE_REGISTRY_URL}/arc-execution:`, where `IMAGE_REGISTRY_URL` is taken from the `.env` file (see [Custom Docker images](#custom-docker-images)). + These are the network-wide defaults; individual nodes or node groups can override them (see [Per-node and per-group images](#per-node-and-per-group-images)). - **image_cl_upgrade**, **image_el_upgrade**: Docker images to use when upgrading containers with `quake perturb upgrade`. Required for upgrade scenarios; not supported in remote mode. +- **group_images**: Per-node-group image overrides, declared as `[group_images.]` with `image_cl`/`image_el` keys. See [Per-node and per-group images](#per-node-and-per-group-images). - **node_size**: EC2 instance type for validator/full nodes (e.g. `"m6a.4xlarge"`). Equivalent to the `--node-size` CLI flag. See [Instance sizing](#instance-sizing) for available options. **Remote mode only** — ignored in local mode (a warning is printed). @@ -866,6 +1100,13 @@ Optional top-level settings: ceiling above the volume type's baseline. Only valid with `gp3`, `io1`, `io2`; range 100–256000. Default: AMI's baseline IOPS for the chosen type. Equivalent to `--node-volume-iops`. **Remote mode only**. +- **node_data_on_instance_store**: When `true`, mounts the local instance-store NVMe at + the node data directory so the EL/CL databases live on local disk instead of the root + EBS volume. Requires an instance type with local NVMe (e.g. `i4i.*`, `i3.*`, `m6id.*`); + multiple instance-store volumes are striped RAID0 into one device. A no-op on instance + types without instance store, leaving the data directory on EBS. Independent of + `node_volume_type`/`node_volume_iops`, which keep configuring the root EBS volume. + Equivalent to `--node-data-on-instance-store`. **Remote mode only**. - **el_cpu_limit**: Hard CPU cap for each EL container; reproduces production CPU quotas on the testnet. Whole or fractional CPUs (e.g. `0.5`). Maps to Docker Compose [`cpus`][compose-cpus]. Default: no limit (container uses all host CPUs). @@ -894,28 +1135,12 @@ Nodes are defined as individual TOML sections with names starting with `validato ### Node Configuration Consensus Layer (CL) configuration is set under `cl.config.*` keys. The -schema depends on the CL image version (`image_cl`): - -- **Modern CL (>= v0.5.0)**: the schema matches the `StartCmd` struct in - [`crates/malachite-cli/src/cmd/start.rs`](../malachite-cli/src/cmd/start.rs). - Keys are flat and map 1:1 to the `arc-node-consensus start` CLI flags - (e.g. `cl.config.log_level = "debug"` → `--log-level=debug`). Quake - translates the merged config into CLI flags at setup time and the node is - launched with no `config.toml`. -- **Legacy CL (< v0.5.0)**: the schema matches the `Config` struct in - [`crates/types/src/config.rs`](../types/src/config.rs). Keys are nested - (e.g. `cl.config.logging.log_level = "debug"`) and the merged config is - written to `config.toml` at setup time. Legacy mode is scheduled for - deprecation. - -Quake detects which schema to use by parsing the `image_cl` tag; `latest`, -missing tags, and unparsable tags are treated as Modern. The two formats -are not interchangeable — `cl.config.log_level` on a Legacy image (and -`cl.config.logging.log_level` on a Modern image) will fail to parse. -Upgrading a running testnet across the legacy/modern boundary with -`perturb upgrade` is **not supported**: the upgraded binary would start -with no CLI flags. For upgrade scenarios, start the testnet on a Modern -version. +schema matches the `StartCmd` struct in +[`crates/malachite-cli/src/cmd/start.rs`](../malachite-cli/src/cmd/start.rs). +Keys are flat and map 1:1 to the `arc-node-consensus start` CLI flags +(e.g. `cl.config.log_level = "debug"` → `--log-level=debug`). Quake +translates the merged config into CLI flags at setup time; the CL does not +read a `config.toml`. #### Matching Flags to the Target Image Version @@ -936,7 +1161,7 @@ The default configuration of Reth (Execution Layer) is defined in [`crates/quake/src/manifest.rs`](src/manifest.rs). It can be set globally or for each node by prefixing the config field with `el.config.`. -For example (Modern CL): +For example: ```toml # Global settings that apply to all nodes @@ -1118,6 +1343,41 @@ el.config.engine.persistence-threshold = 20 > is automatically added to Reth's configuration. You don't need to include it > manually. +### Environment Variables + +In addition to CLI flags (`el.config`/`cl.config`), you can set environment +variables on a node's containers via the `el.env` (Execution Layer) and +`cl.env` (Consensus Layer) tables. They follow the same precedence as config: +global values are inherited by every node and per-node values override matching +keys. + +```toml +# Global: applies to every node's containers +[el.env] +RUST_LOG = "info" +[cl.env] +RUST_LOG = "info" + +[nodes.validator1.el.env] +# Override the global value for this node's EL container only +RUST_LOG = "debug,net::discovery=trace" + +[nodes.validator2.cl.env] +# Halt this node's CL at a given height (testing graceful shutdown) +ARC_HALT_AT_BLOCK_HEIGHT = 100 +``` + +- `el.env` is applied to the EL (Reth) container; `cl.env` to the CL (Malachite) + container. Use the right table for the layer you want to affect. +- Keys must be valid environment variable names (`^[A-Za-z_][A-Za-z0-9_]*$`). +- Values may be strings, integers, floats, or booleans; non-scalars + (arrays/tables) are rejected. All values are emitted as strings. +- Quake sets some environment variables by default (e.g. `RUST_LOG` and + `ARC_LOG_FILE` on the EL container, `ARC_HALT_AT_BLOCK_HEIGHT` on the CL + container). Setting the same key in `el.env`/`cl.env` **replaces** the default + rather than duplicating it. +- Works in both local and remote deployments. + ### Voting Power By default every validator in genesis receives a voting power of 20. To override this, set `cl_voting_power` on each validator node: @@ -1198,6 +1458,50 @@ Note: A node is automatically excluded from its own persistent peers list. **`el.config.trusted_peers` (Execution Layer):** identical behavior to `cl_persistent_peers`. +### Per-node and per-group images + +By default every node runs the global `image_cl`/`image_el` (see +[Basic Structure](#basic-structure)). You can override the base image for +individual nodes or whole node groups, which boots a **mixed-version network +from genesis** without a rolling `perturb upgrade`. Useful for cross-version +consensus testing, pre-rollout validation, and reproducing version skew. + +- **Per node:** set `image_cl`/`image_el` under a `[nodes.]` section. +- **Per group:** set them under `[group_images.]`, keyed by any node + group (custom or a built-in such as `ALL_VALIDATORS`); applied to every member. + +Precedence, lowest to highest: **global image < node-group override < per-node +override**. A node covered by two image-declaring groups for the same layer is +rejected as ambiguous. + +```toml +image_cl = "arc_consensus:latest" # global base images +image_el = "arc_execution:latest" + +[node_groups] +OLDIES = ["validator4", "validator5"] + +# validator4 and validator5 run the pinned release instead of the global image +[group_images.OLDIES] +image_cl = "${IMAGE_REGISTRY_URL}/arc-consensus:0.6.0" +image_el = "${IMAGE_REGISTRY_URL}/arc-execution:0.6.0" + +[nodes.validator1] +[nodes.validator4] +[nodes.full1] +image_el = "${IMAGE_REGISTRY_URL}/arc-execution:0.6.0" # inline override wins +``` + +**Compatibility (operator's responsibility).** Every image in a mixed network +must agree on genesis state, the hardfork schedule, and on-disk db format. Quake +enforces only `arc_consensus >= v0.5.0` and, in remote mode, a `ghcr.io/` +registry for each image; genesis or db mismatches are not caught statically and +fail loudly at startup. The base image is per-node, while the +`image_cl_upgrade`/`image_el_upgrade` used by `perturb upgrade` stay global. + +A worked example ships at +[`scenarios/mixed-version.toml`](scenarios/mixed-version.toml). + ### Starting height By default, all nodes (Consensus Layer and Execution Layer containers) will @@ -1462,6 +1766,30 @@ quake remote create --node-size t3.large --node-disk-gb 100 --cc-disk-gb 100 quake start --remote --node-size t3.large --node-disk-gb 100 ``` +#### Local NVMe vs EBS storage + +By default the node data directory lives on the root EBS volume. The +`--node-data-on-instance-store` flag instead mounts the instance's local NVMe +instance store at the data directory, so the EL/CL databases run on local disk. +It requires an instance type that ships local NVMe (`i4i.*`, `i3.*`, `m6id.*`, +`c6id.*`); on any other type it is a no-op and the data directory stays on EBS. +Instance types with multiple instance-store volumes are striped RAID0 into a single +device. The flag is independent of `--node-volume-type`/`--node-volume-iops`, which +keep tuning the root EBS volume, so the two storage backends can be compared directly. + +```bash +# Datadir on io2 EBS +quake remote create --node-size i4i.xlarge \ + --node-volume-type io2 --node-volume-iops 64000 --node-disk-gb 1000 + +# Datadir on local NVMe (same instance type, only the storage backing differs) +quake remote create --node-size i4i.xlarge --node-data-on-instance-store +``` + +The instance store is ephemeral: its contents are lost when the instance stops or +terminates. That is fine for benchmarking and short-lived testnets, but never use +it for state you need to keep. + #### Node instances Each node runs an Execution Layer (EL) and a Consensus Layer (CL) container, @@ -1644,7 +1972,7 @@ Initialize Terraform plugins and state. This step is required only once. Create EC2 instances for each node in the testnet, plus one extra for the Control Center (CC) server. ```bash -./quake [-f ] remote create [--dry-run] [--yes] [--node-size ] [--cc-size ] [--node-disk-gb ] [--cc-disk-gb ] +./quake [-f ] remote create [--dry-run] [--yes] [--node-size ] [--cc-size ] [--node-disk-gb ] [--cc-disk-gb ] [--node-data-on-instance-store] ``` See [Instance sizing](#instance-sizing) for recommended instance types. @@ -1656,7 +1984,11 @@ tunnels from local ports to remote ports. ```bash ./quake remote ssm start ``` -Note that tunnels are closed automatically after 20 minutes of inactivity. +Note that tunnels are closed automatically after 20 minutes of inactivity. For +long-running experiments, keep them alive in a separate terminal: +```bash +./quake remote ssm keep-alive 2h +``` Once the SSM session are established, we can log in via SSH to a node or CC, or run commands in the instances directly from the terminal: @@ -1713,30 +2045,38 @@ run the spammer directly, remote testnets forward to the Control Center via SSH. All Spammer options are supported. `--targets` accepts comma-separated selectors including manifest node groups such as `ALL_VALIDATORS` or custom `[node_groups]`. -Download diagnostic artifacts from the remote testnet: +Download diagnostic artifacts from the testnet via the mode-agnostic +`quake download` command group (`quake remote download +{metrics,db}` is deprecated but kept as a backward-compatible alias): ```bash -# Download all Prometheus metrics (covers the current head block, ~2h by default) -./quake remote download metrics +# Download both metrics and a single-node db snapshot +./quake download + +# Metrics only — covers the current head block (~2 h) by default +./quake download metrics -# Download metrics for a specific time range -./quake remote download metrics --from 2024-01-15T10:30:00Z --to 2024-01-15T12:00:00Z +# Metrics for a specific time range +./quake download metrics --from 2024-01-15T10:30:00Z --to 2024-01-15T12:00:00Z -# Download specific metrics only (metric names go after --) -./quake remote download metrics -- reth_db_size_bytes go_goroutines +# Specific metrics only (metric names go after --) +./quake download metrics -- reth_db_size_bytes go_goroutines -# Download node databases (both execution and consensus layers, all nodes) -./quake remote download db +# Save to a custom output path +./quake download metrics -o /tmp/my-metrics.tar.gz + +# Download node database (defaults to the first node in the manifest) +./quake download db -# Download execution layer only, from specific nodes -./quake remote download db --execution-only -- validator1 validator2 +# Execution layer only, from specific nodes +./quake download db --execution-only -- validator1 validator2 # Save to a custom output path -./quake remote download metrics -o /tmp/my-metrics.tar.gz -./quake remote download db -o /tmp/my-db.tar.gz +./quake download db -o /tmp/my-db.tar.gz ``` -Both `download` subcommands output a `.tar.gz` archive named `quake-metrics-.tar.gz` / -`quake-db-.tar.gz` unless overridden with `-o`. +Archives land in `.quake/metrics//quake-metrics-.tar.gz` +and `.quake/db//quake-db-.tar.gz` by default; pass `-o` +to override. Once finished with your tests, remember to destroy the remote infrastructure! ```bash @@ -2054,6 +2394,25 @@ Configure RPC timeout for tests (default is 1 second): - `peer_count` - Ensures all nodes have at least one peer connection - `cl_persistent_peers` - Verifies that persistent peers defined in the manifest are actually connected +**infra** - Substrate-level readiness checks (excluded from the default `quake test` run): +- `latency_emulation` - Verifies the `tc netem` rules inside each node's CL and EL containers + match the manifest. When `latency_emulation = true`, cross-checks each peer's expected + delay against `AWS_LATENCY_MATRIX` within ±10% tolerance. When `latency_emulation = + false`, asserts no `netem` qdiscs exist (catches stale rules left over from a prior + latency run). + + > **Known limitation**: the check probes only the container's primary interface + > (`eth0`). Bridge nodes attached to multiple subnets (sentries, relayers) and + > local compose containers connected to `host-access` carry additional `eth*` + > interfaces that also receive `tc netem` rules; the check does not verify those. + > A node passing the check today guarantees the matrix on its primary + > interface only. Broken or stale rules on secondary interfaces are not caught. + +The `infra` group is intended as a pre-experiment readiness gate on a running +testnet, not a CI signal. It shell-execs into every container, so cost scales +with node count; especially relevant on remote testnets where each probe +involves an SSH hop. + ### Adding new tests Tests automatically register themselves using the `#[quake_test]` macro. To add new tests: diff --git a/crates/quake/scenarios/db-upgrade-v0-v1.toml b/crates/quake/scenarios/db-upgrade-v0-v1.toml index 43406508..63d560c7 100644 --- a/crates/quake/scenarios/db-upgrade-v0-v1.toml +++ b/crates/quake/scenarios/db-upgrade-v0-v1.toml @@ -1,8 +1,7 @@ name = "db-upgrade" description = "DB upgrade testing scenario with 3 validators. Tests upgrade of a single validator (call perturb upgrade validator1)." -# Use RPC connection for Engine API -engine_api_connection = "rpc" +engine_api_connection = "ipc" # Starting versions (what the testnet begins with). # Both starting and upgrade images must be Modern (>= v0.5.0) — the upgrade diff --git a/crates/quake/scenarios/examples/5nodes_upgrade.toml b/crates/quake/scenarios/examples/5nodes_upgrade.toml index 4cd1e796..cee1a0f6 100644 --- a/crates/quake/scenarios/examples/5nodes_upgrade.toml +++ b/crates/quake/scenarios/examples/5nodes_upgrade.toml @@ -1,4 +1,4 @@ -engine_api_connection = "rpc" +engine_api_connection = "ipc" # IMAGE_REGISTRY_URL should be defined as an environment variable or in the .env file. image_cl="${IMAGE_REGISTRY_URL}/arc-consensus:0.5.0-rc1" diff --git a/crates/quake/scenarios/examples/byzantine.toml b/crates/quake/scenarios/examples/byzantine.toml new file mode 100644 index 00000000..d40a5601 --- /dev/null +++ b/crates/quake/scenarios/examples/byzantine.toml @@ -0,0 +1,35 @@ +# Byzantine testnet scenario +# +# Requires the consensus binary to be built with the `byzantine` feature. +# Run with: CL_FEATURES=byzantine make testnet QUAKE_MANIFEST=scenarios/examples/byzantine.toml +# +# 7-validator testnet: one honest node per attack for observability. +# - validator1: honest baseline +# - validator2: random vote equivocation (seeded, 30% probability) +# - validator3: silent on votes for heights 10-50 (drop-votes) +# - validator4: always equivocates proposals +# - validator5: amnesia (ignores prevote lock) at height 25 +# - validator6: force-precommits nil for rounds 0 and 1 +# - validator7: drops inbound proposals at heights 40-60 + +[nodes.validator1] +# Honest baseline + +[nodes.validator2] +cl.config.byzantine.equivocate_votes = { mode = "random", probability = 0.3 } +cl.config.byzantine.seed = 12345 + +[nodes.validator3] +cl.config.byzantine.drop_votes = { mode = "height_range", from = 10, to = 50 } + +[nodes.validator4] +cl.config.byzantine.equivocate_proposals = { mode = "always" } + +[nodes.validator5] +cl.config.byzantine.ignore_locks = { mode = "at_heights", heights = [25] } + +[nodes.validator6] +cl.config.byzantine.force_precommit_nil = { mode = "at_rounds", rounds = [0, 1] } + +[nodes.validator7] +cl.config.byzantine.drop_inbound_proposals = { mode = "height_range", from = 40, to = 60 } diff --git a/crates/quake/scenarios/examples/testnet-small-default.toml b/crates/quake/scenarios/examples/testnet-small-default.toml index 3f994bd3..3be59ac5 100644 --- a/crates/quake/scenarios/examples/testnet-small-default.toml +++ b/crates/quake/scenarios/examples/testnet-small-default.toml @@ -10,7 +10,7 @@ cl.config.discovery_num_inbound_peers = 50 cl.config.discovery_num_outbound_peers = 50 latency_emulation = true -engine_api_connection = "rpc" +engine_api_connection = "ipc" monitoring_bind_host = "0.0.0.0" # ============================================================================== @@ -38,34 +38,34 @@ RPC_NODES = ["rpc-full", "arc-node"] # arc-node # ============================================================================== -# --- Core validators (5 across 5 regions) on private1 --- +# --- Core validators on private1 --- [nodes.validator1] -region = "us-east-2" +region = "us-east-1" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] el.config.trusted_peers = ["CORE_VALIDATORS"] [nodes.validator2] -region = "us-west-2" +region = "eu-west-2" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] el.config.trusted_peers = ["CORE_VALIDATORS"] [nodes.validator3] -region = "eu-central-1" +region = "ap-south-1" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] el.config.trusted_peers = ["CORE_VALIDATORS"] [nodes.validator4] -region = "ap-northeast-1" +region = "ca-central-1" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] el.config.trusted_peers = ["CORE_VALIDATORS"] [nodes.validator5] -region = "eu-west-1" +region = "sa-east-1" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] el.config.trusted_peers = ["CORE_VALIDATORS"] @@ -73,14 +73,14 @@ el.config.trusted_peers = ["CORE_VALIDATORS"] # --- Sentries --- [nodes.sentry-1] -region = "us-east-2" +region = "us-east-1" subnets = ["private1", "public"] cl_persistent_peers = ["sentry-2", "CORE_VALIDATORS"] el.config.trusted_peers = ["sentry-2", "CORE_VALIDATORS"] el.config.disable_discovery = true [nodes.sentry-2] -region = "eu-central-1" +region = "ap-south-1" subnets = ["public", "private2"] external = true start_at = 25 @@ -91,7 +91,7 @@ el.config.disable_discovery = true # --- External validator connected via sentry chain on private2 --- [nodes.validator6] -region = "ap-southeast-1" +region = "ap-southeast-2" subnets = ["private2"] external = true start_at = 30 @@ -101,7 +101,7 @@ el.config.trusted_peers = [] # --- RPC full node (non-validator, externally exposed) --- [nodes.rpc-full] -region = "us-west-2" +region = "eu-west-2" subnets = ["public"] external = true start_at = 25 @@ -116,7 +116,7 @@ el.config.prune.preset = "full" # --- Snapshot node (full profile, snapshot source) --- [nodes.snapshot] -region = "us-east-2" +region = "us-east-1" subnets = ["public"] external = true start_at = 30 @@ -130,7 +130,7 @@ el.config.prune.preset = "full" # --- Arc node (RPC follow from rpc-full and snapshot) --- [nodes.arc-node] -region = "us-east-2" +region = "us-east-1" subnets = ["public"] external = true start_at = 30 diff --git a/crates/quake/scenarios/examples/testnet-small.toml b/crates/quake/scenarios/examples/testnet-small.toml index fe499b28..244d7c95 100644 --- a/crates/quake/scenarios/examples/testnet-small.toml +++ b/crates/quake/scenarios/examples/testnet-small.toml @@ -1,5 +1,5 @@ name = "testnet-small" -description = "Topology with 6 geo-distributed validators, 2 sentries, 1 RPC full node, 1 snapshot node, 1 arc node." +description = "Example topology: 6 geo-distributed validators, 2 sentries, 1 RPC full node, 1 snapshot node, 1 arc node." # ============================================================================== # GLOBAL CONFIGURATION @@ -10,7 +10,7 @@ cl.config.discovery_num_inbound_peers = 50 cl.config.discovery_num_outbound_peers = 50 latency_emulation = true -engine_api_connection = "rpc" +engine_api_connection = "ipc" monitoring_bind_host = "0.0.0.0" # ============================================================================== @@ -38,39 +38,39 @@ RPC_NODES = ["rpc-full", "arc-node"] # arc-node # ============================================================================== -# --- Core validators (5 across 5 regions) on private1 --- +# --- Core validators on private1 --- # Explicit peering among validators guarantees eager push regardless of mesh_n. [nodes.validator1] -region = "us-east-2" +region = "us-east-1" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] cl_gossipsub.explicit_peering = true el.config.trusted_peers = ["CORE_VALIDATORS"] [nodes.validator2] -region = "us-west-2" +region = "eu-west-2" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] cl_gossipsub.explicit_peering = true el.config.trusted_peers = ["CORE_VALIDATORS"] [nodes.validator3] -region = "eu-central-1" +region = "ap-south-1" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] cl_gossipsub.explicit_peering = true el.config.trusted_peers = ["CORE_VALIDATORS"] [nodes.validator4] -region = "ap-northeast-1" +region = "ca-central-1" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] cl_gossipsub.explicit_peering = true el.config.trusted_peers = ["CORE_VALIDATORS"] [nodes.validator5] -region = "eu-west-1" +region = "sa-east-1" subnets = ["private1"] cl_persistent_peers = ["CORE_VALIDATORS"] cl_gossipsub.explicit_peering = true @@ -81,7 +81,7 @@ el.config.trusted_peers = ["CORE_VALIDATORS"] # (explicit peering would cause sentries to PRUNE GRAFTs from validators). [nodes.sentry-1] -region = "us-east-2" +region = "us-east-1" subnets = ["private1", "public"] cl_persistent_peers = ["sentry-2", "CORE_VALIDATORS"] cl_gossipsub.mesh_prioritization = true @@ -90,7 +90,7 @@ el.config.trusted_peers = ["sentry-2", "CORE_VALIDATORS"] el.config.disable_discovery = true [nodes.sentry-2] -region = "eu-central-1" +region = "ap-south-1" subnets = ["public", "private2"] external = true start_at = 25 @@ -103,7 +103,7 @@ el.config.disable_discovery = true # --- External validator connected via sentry chain on private2 --- [nodes.validator6] -region = "ap-southeast-1" +region = "ap-southeast-2" subnets = ["private2"] external = true start_at = 30 @@ -113,7 +113,7 @@ el.config.trusted_peers = [] # --- RPC full node (non-validator, externally exposed) --- [nodes.rpc-full] -region = "us-west-2" +region = "eu-west-2" subnets = ["public"] external = true start_at = 25 @@ -128,7 +128,7 @@ el.config.prune.preset = "full" # --- Snapshot node (full profile, snapshot source) --- [nodes.snapshot] -region = "us-east-2" +region = "us-east-1" subnets = ["public"] external = true start_at = 30 @@ -142,7 +142,7 @@ el.config.prune.preset = "full" # --- Arc node (RPC follow from rpc-full and snapshot) --- [nodes.arc-node] -region = "us-east-2" +region = "us-east-1" subnets = ["public"] external = true start_at = 30 diff --git a/crates/quake/scenarios/mixed-version.toml b/crates/quake/scenarios/mixed-version.toml new file mode 100644 index 00000000..a87fc7a3 --- /dev/null +++ b/crates/quake/scenarios/mixed-version.toml @@ -0,0 +1,36 @@ +name = "mixed-version" +description = "Heterogeneous-version network from genesis: most nodes on the local build, a pinned older group, and one inline-pinned node. Exercises cross-version consensus without a rolling upgrade." + +engine_api_connection = "ipc" + +# Global images: every node uses these unless it (or its group) overrides them. +image_cl = "arc_consensus:latest" +image_el = "arc_execution:latest" + +# COMPATIBILITY: all images in a mixed network must agree on genesis state, the +# hardfork schedule, and on-disk db format. Quake only enforces CL >= v0.5.0 +# (and, for remote, a ghcr.io registry); genesis/db agreement is the operator's +# job. Images must be shell-capable `-dev` flavors (plain release tags are +# distroless, have no /bin/sh, and die at container startup). +el_init_hardfork = "zero6" + +# A pinned older group: both members run this released version instead of the +# global image. IMAGE_REGISTRY_URL comes from the environment or the .env file. +[node_groups] +OLDIES = ["validator4", "validator5"] + +[group_images.OLDIES] +image_cl = "${IMAGE_REGISTRY_URL}/arc-consensus:0.7.0-dev" +image_el = "${IMAGE_REGISTRY_URL}/arc-execution:0.7.0-dev" + +[nodes.validator1] +[nodes.validator2] +[nodes.validator3] + +# Group members: inherit the OLDIES override above. +[nodes.validator4] +[nodes.validator5] + +# Inline override wins over both the group and the global image. +[nodes.full1] +image_el = "${IMAGE_REGISTRY_URL}/arc-execution:0.7.0-dev" diff --git a/crates/quake/scenarios/nightly-chaos-testing.toml b/crates/quake/scenarios/nightly-chaos-testing.toml index de55903d..0ac27a77 100644 --- a/crates/quake/scenarios/nightly-chaos-testing.toml +++ b/crates/quake/scenarios/nightly-chaos-testing.toml @@ -4,76 +4,67 @@ name = "nightly-chaos-testing" cl.config.log_level = "debug" latency_emulation = true -engine_api_connection = "rpc" +engine_api_connection = "ipc" # Topology: -# 10 validators in 4 AWS regions -# - blue, green, purple: us-east-2 -# - cyan: us-west-2 -# - red, orange, yellow: eu-central-1 -# - pink, grey, brown: ap-northeast-1 -# -# 2 full nodes in 2 locations -# -# Internal full nodes: -# - 1 full node - us-east-1 -# - 1 full node - us-west-2 +# 10 validators across 4 geographic regions +# 2 first-party full nodes across 2 geographic regions -# us-east-2 validators -[nodes.validator-blue] -region = "us-east-2" -cl_persistent_peers = ["validator-green", "validator-purple", "validator-cyan", "validator-red", "validator-orange", "validator-yellow", "validator-pink", "validator-grey", "validator-brown"] +# us-east-1 validators +[nodes.validator-1] +region = "us-east-1" +cl_persistent_peers = ["validator-2", "validator-3", "validator-4", "validator-5", "validator-6", "validator-7", "validator-8", "validator-9", "validator-10"] -[nodes.validator-green] -region = "us-east-2" -cl_persistent_peers = ["validator-blue", "validator-purple", "validator-cyan", "validator-red", "validator-orange", "validator-yellow", "validator-pink", "validator-grey", "validator-brown"] +[nodes.validator-2] +region = "us-east-1" +cl_persistent_peers = ["validator-1", "validator-3", "validator-4", "validator-5", "validator-6", "validator-7", "validator-8", "validator-9", "validator-10"] -[nodes.validator-purple] -region = "us-east-2" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-cyan", "validator-red", "validator-orange", "validator-yellow", "validator-pink", "validator-grey", "validator-brown"] +[nodes.validator-3] +region = "us-east-1" +cl_persistent_peers = ["validator-1", "validator-2", "validator-4", "validator-5", "validator-6", "validator-7", "validator-8", "validator-9", "validator-10"] -# us-west-2 validator -[nodes.validator-cyan] -region = "us-west-2" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-purple", "validator-red", "validator-orange", "validator-yellow", "validator-pink", "validator-grey", "validator-brown"] +# eu-west-2 validator +[nodes.validator-4] +region = "eu-west-2" +cl_persistent_peers = ["validator-1", "validator-2", "validator-3", "validator-5", "validator-6", "validator-7", "validator-8", "validator-9", "validator-10"] -# eu-central-1 validators -[nodes.validator-red] -region = "eu-central-1" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-purple", "validator-cyan", "validator-orange", "validator-yellow", "validator-pink", "validator-grey", "validator-brown"] +# ap-south-1 validators +[nodes.validator-5] +region = "ap-south-1" +cl_persistent_peers = ["validator-1", "validator-2", "validator-3", "validator-4", "validator-6", "validator-7", "validator-8", "validator-9", "validator-10"] -[nodes.validator-orange] -region = "eu-central-1" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-purple", "validator-cyan", "validator-red", "validator-yellow", "validator-pink", "validator-grey", "validator-brown"] +[nodes.validator-6] +region = "ap-south-1" +cl_persistent_peers = ["validator-1", "validator-2", "validator-3", "validator-4", "validator-5", "validator-7", "validator-8", "validator-9", "validator-10"] -[nodes.validator-yellow] -region = "eu-central-1" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-purple", "validator-cyan", "validator-red", "validator-orange", "validator-pink", "validator-grey", "validator-brown"] +[nodes.validator-7] +region = "ap-south-1" +cl_persistent_peers = ["validator-1", "validator-2", "validator-3", "validator-4", "validator-5", "validator-6", "validator-8", "validator-9", "validator-10"] -# ap-northeast-1 validators -[nodes.validator-pink] -region = "ap-northeast-1" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-purple", "validator-cyan", "validator-red", "validator-orange", "validator-yellow", "validator-grey", "validator-brown"] +# ca-central-1 validators +[nodes.validator-8] +region = "ca-central-1" +cl_persistent_peers = ["validator-1", "validator-2", "validator-3", "validator-4", "validator-5", "validator-6", "validator-7", "validator-9", "validator-10"] start_at = 30 -[nodes.validator-grey] -region = "ap-northeast-1" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-purple", "validator-cyan", "validator-red", "validator-orange", "validator-yellow", "validator-pink", "validator-brown"] +[nodes.validator-9] +region = "ca-central-1" +cl_persistent_peers = ["validator-1", "validator-2", "validator-3", "validator-4", "validator-5", "validator-6", "validator-7", "validator-8", "validator-10"] start_at = 30 -[nodes.validator-brown] -region = "ap-northeast-1" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-purple", "validator-cyan", "validator-red", "validator-orange", "validator-yellow", "validator-pink", "validator-grey"] +[nodes.validator-10] +region = "ca-central-1" +cl_persistent_peers = ["validator-1", "validator-2", "validator-3", "validator-4", "validator-5", "validator-6", "validator-7", "validator-8", "validator-9"] start_at = 30 -# us-east-1 full node -[nodes.full-stg-blue] -region = "us-east-1" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-purple", "validator-cyan", "validator-red", "validator-orange", "validator-yellow", "validator-pink", "validator-grey", "validator-brown"] +# sa-east-1 full node +[nodes.full-1] +region = "sa-east-1" +cl_persistent_peers = ["validator-1", "validator-2", "validator-3", "validator-4", "validator-5", "validator-6", "validator-7", "validator-8", "validator-9", "validator-10"] start_at = 150 -# us-west-2 full node -[nodes.full-prod-teal] -region = "us-west-2" -cl_persistent_peers = ["validator-blue", "validator-green", "validator-purple", "validator-cyan", "validator-red", "validator-orange", "validator-yellow", "validator-pink", "validator-grey", "validator-brown"] +# eu-west-2 full node +[nodes.full-2] +region = "eu-west-2" +cl_persistent_peers = ["validator-1", "validator-2", "validator-3", "validator-4", "validator-5", "validator-6", "validator-7", "validator-8", "validator-9", "validator-10"] start_at = 150 diff --git a/crates/quake/scenarios/nightly-upgrade.toml b/crates/quake/scenarios/nightly-upgrade.toml index 3d60aae2..6ac753b3 100644 --- a/crates/quake/scenarios/nightly-upgrade.toml +++ b/crates/quake/scenarios/nightly-upgrade.toml @@ -1,8 +1,7 @@ name = "nightly-upgrade" description = "Nightly upgrade testing scenario with 5 validators. Tests rolling upgrade with configurable delay between stop and restart. Use 'quake wait rounds' after upgrades to verify consensus health." -# Use RPC connection for Engine API -engine_api_connection = "rpc" +engine_api_connection = "ipc" # Starting versions (what the testnet begins with) # IMAGE_REGISTRY_URL should be defined as an environment variable or in the .env file. @@ -10,7 +9,7 @@ image_cl="${IMAGE_REGISTRY_URL}/arc-consensus:latest" image_el="${IMAGE_REGISTRY_URL}/arc-execution:latest" # Set the execution layer initial hardfork to the highest version supported by the image. -el_init_hardfork="zero4" +el_init_hardfork="zero6" # Upgrade versions (what we upgrade to during the test) image_cl_upgrade="arc_consensus:latest" diff --git a/crates/quake/scenarios/public-testnet.toml b/crates/quake/scenarios/public-testnet.toml index 9ade2c2f..ec3f2f5a 100644 --- a/crates/quake/scenarios/public-testnet.toml +++ b/crates/quake/scenarios/public-testnet.toml @@ -8,153 +8,137 @@ cl.config.discovery_num_inbound_peers = 50 cl.config.discovery_num_outbound_peers = 50 latency_emulation = true -engine_api_connection = "rpc" +engine_api_connection = "ipc" # Topology (testnet reproduction): -# 10 validators in 4 AWS regions -# - blue, green, purple: us-east-2 -# - cyan: us-west-2 -# - red, orange, yellow: eu-central-1 -# - pink, grey, brown: ap-northeast-1 -# -# 15 full nodes in 5 locations -# -# Internal full nodes: -# - 5 full nodes - us-east-1 -# - 1 full node - us-west-2 -# -# External full nodes: -# (Note: exact locations and counts are unknown; this is an approximation -# based on the number of connections observed in validator metrics) -# - 6 full nodes - eu-west-2 (QuickNode) -# - 1 full node - us-west-1 (Alchemy) -# - 1 full node - us-west-2 (Blockdaemon) -# - 1 full node - eu-central-1 (DRPC) +# 10 validators across 4 geographic regions +# 15 full nodes across multiple geographic regions, +# including both first-party and third-party operator nodes [node_groups] -US_EU_VALIDATORS = ["validator-blue", "validator-green", "validator-purple", "validator-cyan", "validator-red", "validator-orange", "validator-yellow"] +PRIMARY_VALIDATORS = ["validator-1", "validator-2", "validator-3", "validator-4", "validator-5", "validator-6", "validator-7"] -# us-east-2 validators -[nodes.validator-blue] -region = "us-east-2" +# us-east-1 validators +[nodes.validator-1] +region = "us-east-1" cl_persistent_peers = ["ALL_VALIDATORS"] -[nodes.validator-green] -region = "us-east-2" +[nodes.validator-2] +region = "us-east-1" cl_persistent_peers = ["ALL_VALIDATORS"] -[nodes.validator-purple] -region = "us-east-2" +[nodes.validator-3] +region = "us-east-1" cl_persistent_peers = ["ALL_VALIDATORS"] -# us-west-2 validator -[nodes.validator-cyan] -region = "us-west-2" +# eu-west-2 validator +[nodes.validator-4] +region = "eu-west-2" cl_persistent_peers = ["ALL_VALIDATORS"] -# eu-central-1 validators -[nodes.validator-red] -region = "eu-central-1" +# ap-south-1 validators +[nodes.validator-5] +region = "ap-south-1" cl_persistent_peers = ["ALL_VALIDATORS"] -[nodes.validator-orange] -region = "eu-central-1" +[nodes.validator-6] +region = "ap-south-1" cl_persistent_peers = ["ALL_VALIDATORS"] -[nodes.validator-yellow] -region = "eu-central-1" +[nodes.validator-7] +region = "ap-south-1" cl_persistent_peers = ["ALL_VALIDATORS"] -# ap-northeast-1 validators -[nodes.validator-pink] -region = "ap-northeast-1" +# ca-central-1 validators +[nodes.validator-8] +region = "ca-central-1" cl_persistent_peers = ["ALL_VALIDATORS"] start_at = 30 -[nodes.validator-grey] -region = "ap-northeast-1" +[nodes.validator-9] +region = "ca-central-1" cl_persistent_peers = ["ALL_VALIDATORS"] start_at = 30 -[nodes.validator-brown] -region = "ap-northeast-1" +[nodes.validator-10] +region = "ca-central-1" cl_persistent_peers = ["ALL_VALIDATORS"] start_at = 30 -# us-east-1 full nodes -[nodes.full-stg-blue] -region = "us-east-1" +# sa-east-1 full nodes +[nodes.full-1] +region = "sa-east-1" cl_persistent_peers = ["ALL_VALIDATORS"] start_at = 150 -[nodes.full-stg-green] -region = "us-east-1" +[nodes.full-2] +region = "sa-east-1" cl_persistent_peers = ["ALL_VALIDATORS"] start_at = 150 -[nodes.full-blue] -region = "us-east-1" +[nodes.full-3] +region = "sa-east-1" cl_persistent_peers = ["ALL_VALIDATORS"] start_at = 20 -[nodes.full-green] -region = "us-east-1" +[nodes.full-4] +region = "sa-east-1" cl_persistent_peers = ["ALL_VALIDATORS"] start_at = 20 -[nodes.full-stg-purple] -region = "us-east-1" +[nodes.full-5] +region = "sa-east-1" cl_persistent_peers = ["ALL_VALIDATORS"] start_at = 150 -# us-west-2 full node -[nodes.full-prod-teal] -region = "us-west-2" +# eu-west-2 full node +[nodes.full-6] +region = "eu-west-2" cl_persistent_peers = ["ALL_VALIDATORS"] start_at = 150 -# external full nodes -[nodes.full-quicknode1] -region = "eu-west-2" -cl_persistent_peers = ["US_EU_VALIDATORS"] +# third-party full nodes +[nodes.full-ext-1] +region = "ap-southeast-2" +cl_persistent_peers = ["PRIMARY_VALIDATORS"] start_at = 50 -[nodes.full-quicknode2] -region = "eu-west-2" -cl_persistent_peers = ["US_EU_VALIDATORS"] +[nodes.full-ext-2] +region = "ap-southeast-2" +cl_persistent_peers = ["PRIMARY_VALIDATORS"] start_at = 50 -[nodes.full-quicknode3] -region = "eu-west-2" -cl_persistent_peers = ["US_EU_VALIDATORS"] +[nodes.full-ext-3] +region = "ap-southeast-2" +cl_persistent_peers = ["PRIMARY_VALIDATORS"] start_at = 50 -[nodes.full-quicknode4] -region = "eu-west-2" -cl_persistent_peers = ["US_EU_VALIDATORS"] +[nodes.full-ext-4] +region = "ap-southeast-2" +cl_persistent_peers = ["PRIMARY_VALIDATORS"] start_at = 50 -[nodes.full-quicknode5] -region = "eu-west-2" -cl_persistent_peers = ["US_EU_VALIDATORS"] +[nodes.full-ext-5] +region = "ap-southeast-2" +cl_persistent_peers = ["PRIMARY_VALIDATORS"] start_at = 50 -[nodes.full-quicknode6] -region = "eu-west-2" -cl_persistent_peers = ["US_EU_VALIDATORS"] +[nodes.full-ext-6] +region = "ap-southeast-2" +cl_persistent_peers = ["PRIMARY_VALIDATORS"] start_at = 50 -[nodes.full-alchemy1] +[nodes.full-ext-7] region = "us-west-1" -cl_persistent_peers = ["US_EU_VALIDATORS"] +cl_persistent_peers = ["PRIMARY_VALIDATORS"] start_at = 50 -[nodes.full-blockdaemon1] -region = "us-west-2" -cl_persistent_peers = ["US_EU_VALIDATORS"] +[nodes.full-ext-8] +region = "eu-west-2" +cl_persistent_peers = ["PRIMARY_VALIDATORS"] start_at = 50 -[nodes.full-drpc1] -region = "eu-central-1" -cl_persistent_peers = ["US_EU_VALIDATORS"] +[nodes.full-ext-9] +region = "ap-south-1" +cl_persistent_peers = ["PRIMARY_VALIDATORS"] start_at = 50 diff --git a/crates/quake/scenarios/storage-v2-migration.toml b/crates/quake/scenarios/storage-v2-migration.toml new file mode 100644 index 00000000..44b6fcd1 --- /dev/null +++ b/crates/quake/scenarios/storage-v2-migration.toml @@ -0,0 +1,23 @@ +# 5-validator network; all EL nodes start in Storage V1 (legacy MDBX-only) layout, +# archive retention (no pruning). Used by the V1->V2 migration smoke test: one node is +# migrated to V2 mid-test, proving a mixed V1/V2 network reaches consensus. +# Temporary — remove once the storage-v2 migration phase has shipped. + +# Start with V1 storage +[el.config.storage] +v2 = false + +[nodes.validator1] +cl_suggested_fee_recipient = "0x1111111111111111111111111111111111111111" + +[nodes.validator2] +cl_suggested_fee_recipient = "0x2222222222222222222222222222222222222222" + +[nodes.validator3] +cl_suggested_fee_recipient = "0x3333333333333333333333333333333333333333" + +[nodes.validator4] +cl_suggested_fee_recipient = "0x4444444444444444444444444444444444444444" + +[nodes.validator5] +cl_suggested_fee_recipient = "0x5555555555555555555555555555555555555555" diff --git a/crates/quake/scenarios/tx-relay-failover.toml b/crates/quake/scenarios/tx-relay-failover.toml new file mode 100644 index 00000000..a666b8d4 --- /dev/null +++ b/crates/quake/scenarios/tx-relay-failover.toml @@ -0,0 +1,25 @@ +name = "tx-relay-failover" +description = "Full node relays tx submission to validators with failover; kill the primary relay target and confirm the full node still accepts and mines transactions." + +# 4 validators: killing the primary relay target (validator1) must leave the +# rest of the network able to keep producing blocks. 3/4 = 75% voting power +# stays online, safely above the >2/3 BFT quorum threshold. 3 validators would +# leave exactly 2/3 online, which is not strictly greater than 2/3 and risks a +# stalled/flaky test. +[nodes.validator1] +cl_suggested_fee_recipient = "0x1111111111111111111111111111111111111111" + +[nodes.validator2] +cl_suggested_fee_recipient = "0x2222222222222222222222222222222222222222" + +[nodes.validator3] +cl_suggested_fee_recipient = "0x3333333333333333333333333333333333333333" + +[nodes.validator4] +cl_suggested_fee_recipient = "0x4444444444444444444444444444444444444444" + +# Relays raw tx submission to validator1 (primary) with failover to +# validator2 (backup). Local Docker Compose service DNS resolves these +# hostnames directly. +[nodes.full1] +el.config.arc.tx.relays = ["http://validator1_el:8545", "http://validator2_el:8545"] diff --git a/crates/quake/src/build.rs b/crates/quake/src/build.rs index a495d809..2c208330 100644 --- a/crates/quake/src/build.rs +++ b/crates/quake/src/build.rs @@ -14,7 +14,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{setup, testnet}; +use std::collections::HashSet; + +use crate::setup; /// Check if a Docker image should be built locally. /// @@ -25,58 +27,89 @@ fn should_build_locally(image: &str) -> bool { !image.starts_with("ghcr.io/") } -fn push_build_if_local(builds: &mut Vec, tag: &str, service_name: &str) { - if should_build_locally(tag) { - builds.push(setup::ImageBuild { - service_name: service_name.to_string(), - tag: tag.to_string(), - }); +/// Compose service name for the nth local build of a layer. The first build keeps +/// the historical base name so single-image scenarios generate identical output. +fn build_service_name(base: &str, index: usize) -> String { + if index == 0 { + base.to_string() + } else { + format!("{base}_{index}") } } -fn maybe_push_build(builds: &mut Vec, tag: Option<&String>, service_name: &str) { - if let Some(tag) = tag { - push_build_if_local(builds, tag, service_name); +/// Local build targets for one layer: every distinct locally-built node image, +/// plus the layer's upgrade image, each with a unique compose service name. +/// `images` may contain duplicates and remote (ghcr.io) references; both are skipped. +fn local_builds( + images: &[String], + upgrade: Option<&String>, + base_name: &str, + upgrade_name: &str, +) -> Vec { + let mut builds = Vec::new(); + let mut seen = HashSet::new(); + for tag in images { + if should_build_locally(tag) && seen.insert(tag.clone()) { + builds.push(setup::ImageBuild { + service_name: build_service_name(base_name, builds.len()), + tag: tag.clone(), + }); + } + } + if let Some(tag) = upgrade { + if should_build_locally(tag) && seen.insert(tag.clone()) { + builds.push(setup::ImageBuild { + service_name: upgrade_name.to_string(), + tag: tag.clone(), + }); + } } + builds } -/// Build lists of local Docker images to build (excluding remote images). +/// Build lists of local Docker images to build (excluding remote images), covering +/// every distinct per-node image plus the global upgrade images. pub(crate) fn local_images_to_build( - images: &testnet::DockerImages, + el_images: &[String], + cl_images: &[String], + el_upgrade: Option<&String>, + cl_upgrade: Option<&String>, ) -> (Vec, Vec) { - let mut reth_builds = Vec::new(); - let mut malachite_builds = Vec::new(); - - push_build_if_local(&mut reth_builds, &images.el, "arc_execution_build"); - push_build_if_local(&mut malachite_builds, &images.cl, "arc_consensus_build"); - maybe_push_build( - &mut reth_builds, - images.el_upgrade.as_ref(), - "arc_execution_upgrade_build", - ); - maybe_push_build( - &mut malachite_builds, - images.cl_upgrade.as_ref(), - "arc_consensus_upgrade_build", - ); - - (reth_builds, malachite_builds) + ( + local_builds( + el_images, + el_upgrade, + "arc_execution_build", + "arc_execution_upgrade_build", + ), + local_builds( + cl_images, + cl_upgrade, + "arc_consensus_build", + "arc_consensus_upgrade_build", + ), + ) } -/// Return the list of remote Docker images that need to be pulled. -pub(crate) fn remote_images_to_pull(images: &testnet::DockerImages) -> Vec { +/// Return the distinct remote (ghcr.io) Docker images that need to be pulled from +/// the given set of all effective images. +pub(crate) fn remote_images_to_pull(images: &[String]) -> Vec { + let mut seen = HashSet::new(); images - .all() - .into_iter() + .iter() .filter(|img| !should_build_locally(img)) - .map(|s| s.to_string()) + .filter(|img| seen.insert(img.as_str())) + .cloned() .collect() } #[cfg(test)] mod tests { use super::*; - use crate::testnet::DockerImages; + + fn tags(builds: &[setup::ImageBuild]) -> Vec<&str> { + builds.iter().map(|b| b.tag.as_str()).collect() + } #[test] fn test_should_build_locally() { @@ -96,27 +129,23 @@ mod tests { #[test] fn test_remote_images_to_pull_all_local() { - let images = DockerImages { - cl: "arc_consensus:latest".to_string(), - el: "arc_execution:latest".to_string(), - cl_upgrade: None, - el_upgrade: None, - }; - let remote = remote_images_to_pull(&images); - assert!(remote.is_empty()); + let images = [ + "arc_consensus:latest".to_string(), + "arc_execution:latest".to_string(), + ]; + assert!(remote_images_to_pull(&images).is_empty()); } #[test] fn test_remote_images_to_pull_mixed() { - let images = DockerImages { - cl: "ghcr.io/org-name/repo-name/cl-image:0.5.0-rc1".to_string(), - el: "ghcr.io/org-name/repo-name/el-image:0.5.0-rc1".to_string(), - cl_upgrade: Some("arc_consensus:latest".to_string()), - el_upgrade: Some("arc_execution:latest".to_string()), - }; - let remote = remote_images_to_pull(&images); + let images = [ + "ghcr.io/org-name/repo-name/cl-image:0.5.0-rc1".to_string(), + "ghcr.io/org-name/repo-name/el-image:0.5.0-rc1".to_string(), + "arc_consensus:latest".to_string(), + "arc_execution:latest".to_string(), + ]; assert_eq!( - remote, + remote_images_to_pull(&images), vec![ "ghcr.io/org-name/repo-name/cl-image:0.5.0-rc1", "ghcr.io/org-name/repo-name/el-image:0.5.0-rc1", @@ -125,20 +154,63 @@ mod tests { } #[test] - fn test_remote_images_to_pull_all_remote() { - let images = DockerImages { - cl: "ghcr.io/org-name/repo-name/cl-image:latest".to_string(), - el: "ghcr.io/org-name/repo-name/el-image:latest".to_string(), - cl_upgrade: None, - el_upgrade: None, - }; - let remote = remote_images_to_pull(&images); + fn remote_images_to_pull_includes_per_node_ghcr_override() { + // A per-node ghcr.io override must be pulled, not only the global images. + let images = [ + "arc_consensus:latest".to_string(), + "arc_execution:latest".to_string(), + "ghcr.io/org-name/repo-name/el-image:0.6.0".to_string(), + ]; assert_eq!( - remote, - vec![ - "ghcr.io/org-name/repo-name/cl-image:latest", - "ghcr.io/org-name/repo-name/el-image:latest", - ] + remote_images_to_pull(&images), + vec!["ghcr.io/org-name/repo-name/el-image:0.6.0"] + ); + } + + #[test] + fn local_images_to_build_covers_per_node_overrides() { + // Two distinct local EL images produce two builds with unique service names; + // the historical base name is preserved for the first. + let el = [ + "arc_execution:latest".to_string(), + "arc_execution:old".to_string(), + ]; + let cl = ["arc_consensus:latest".to_string()]; + let (reth, malachite) = local_images_to_build(&el, &cl, None, None); + + assert_eq!( + tags(&reth), + vec!["arc_execution:latest", "arc_execution:old"] + ); + assert_eq!(reth[0].service_name, "arc_execution_build"); + let names: HashSet<&str> = reth.iter().map(|b| b.service_name.as_str()).collect(); + assert_eq!(names.len(), 2, "service names must be unique"); + assert_eq!(tags(&malachite), vec!["arc_consensus:latest"]); + } + + #[test] + fn local_images_to_build_skips_ghcr_and_dedupes() { + // ghcr images are pulled, not built; duplicates collapse to one build. + let el = [ + "arc_execution:latest".to_string(), + "arc_execution:latest".to_string(), + "ghcr.io/org/el:0.6.0".to_string(), + ]; + let cl = ["arc_consensus:latest".to_string()]; + let (reth, _) = local_images_to_build(&el, &cl, None, None); + assert_eq!(tags(&reth), vec!["arc_execution:latest"]); + } + + #[test] + fn local_images_to_build_includes_upgrade_images() { + let el = ["arc_execution:latest".to_string()]; + let cl = ["arc_consensus:latest".to_string()]; + let el_up = "arc_execution:next".to_string(); + let (reth, _) = local_images_to_build(&el, &cl, Some(&el_up), None); + assert_eq!( + tags(&reth), + vec!["arc_execution:latest", "arc_execution:next"] ); + assert_eq!(reth[1].service_name, "arc_execution_upgrade_build"); } } diff --git a/crates/quake/src/cli_version.rs b/crates/quake/src/cli_version.rs index 695e97ba..2ca0daa7 100644 --- a/crates/quake/src/cli_version.rs +++ b/crates/quake/src/cli_version.rs @@ -14,14 +14,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Version checks and compatibility shims for `arc-node-consensus` images. +//! Version checks and compatibility shim for `arc-node-consensus` images. //! -//! Two layers: -//! -//! * [`check_cli_version`] / [`supports_cli_flags`] decide whether a given -//! target image is started with CLI flags (v0.5.0 and newer) or with a -//! `config.toml` file (pre-v0.5.0). These helpers go away with the legacy -//! `config.toml` code path once every scenario uses v0.5.0 or later. +//! * [`ensure_cl_image_supported`] rejects images older than v0.5.0, which +//! required a `config.toml` that Quake no longer generates. //! //! * [`apply_version_compat`] rewrites the CLI flag list produced by the //! current Quake binary so it matches the target image's `StartCmd` @@ -30,70 +26,38 @@ //! is long-lived — it stays as long as Quake runs against older //! released images. -/// The minimum version that supports CLI flags instead of `config.toml`. -const MIN_CLI_FLAGS_VERSION: (u64, u64, u64) = (0, 5, 0); - -////////////////////////////////////////////////////////////////// -// TODO: Remove once the network is fully migrated to use CLI flags. I.e when -// all scenarios use v0.5.0 or later. -////////////////////////////////////////////////////////////////// +use color_eyre::eyre::{bail, Result}; -/// Result of checking whether a CL image supports CLI flags. -#[derive(Debug, Clone, PartialEq)] -pub(crate) enum CliVersionCheck { - /// Version parsed and supports CLI flags (>= v0.5.0) - SupportsCli, - /// Version parsed and does NOT support CLI flags (< v0.5.0, needs config.toml) - RequiresConfigToml, - /// Version could not be parsed (e.g. git SHA); assumed to support CLI flags - Assumed, -} +/// Released CL versions referenced as boundaries by [`apply_version_compat`] +/// and as the minimum supported version by [`ensure_cl_image_supported`]. +const V0_5_0: (u64, u64, u64) = (0, 5, 0); +const V0_6_0: (u64, u64, u64) = (0, 6, 0); -/// Detailed version check for safeguard validation. -pub(crate) fn check_cli_version(image_tag: Option<&str>) -> CliVersionCheck { - let Some(tag) = image_tag else { - return CliVersionCheck::Assumed; - }; - let version_str = tag.rsplit(':').next().unwrap_or(tag); - if version_str == "latest" { - return CliVersionCheck::SupportsCli; - } - match parse_image_semver(image_tag) { - Some(version) if version >= MIN_CLI_FLAGS_VERSION => CliVersionCheck::SupportsCli, - Some(_) => CliVersionCheck::RequiresConfigToml, - None => CliVersionCheck::Assumed, +/// Reject any `arc-node-consensus` image that pins a release older than +/// v0.5.0. Releases before v0.5.0 are driven by a `config.toml`, which Quake +/// no longer generates, so they would start with no consensus configuration. +/// `None`, `"latest"`, and unparsable tags are assumed current and pass. +pub(crate) fn ensure_cl_image_supported(image_tag: Option<&str>) -> Result<()> { + if let Some(version) = parse_image_semver(image_tag) { + if version < V0_5_0 { + bail!( + "arc-node-consensus image '{}' predates v0.5.0; releases before \ + v0.5.0 require a config.toml that Quake no longer generates. \ + Pin v0.5.0 or newer.", + image_tag.unwrap_or_default(), + ); + } } + Ok(()) } -/// Check if an image tag version supports CLI flags. -/// -/// Returns `false` only for versions definitively older than v0.5.0. -/// Returns `true` for `latest`, `None`, versions >= v0.5.0, and unparsable tags -/// (which are assumed to be v0.5.0+). -/// -/// See [`check_cli_version`] for finer-grained distinction between confirmed -/// and assumed support. -pub(crate) fn supports_cli_flags(image_tag: Option<&str>) -> bool { - check_cli_version(image_tag) != CliVersionCheck::RequiresConfigToml -} - -////////////////////////////////////////////////////////////////// -// END OF TODO: Remove once the network is fully migrated to use CLI flags. I.e -// when all scenarios use v0.5.0 or later. -////////////////////////////////////////////////////////////////// - -/// Released CL versions referenced as boundaries by [`apply_version_compat`]. -const V0_5_0: (u64, u64, u64) = (0, 5, 0); -const V0_6_0: (u64, u64, u64) = (0, 6, 0); - /// Extract a `(major, minor, patch)` tuple from an image tag. /// /// Returns `Some` only for explicit parseable versions such as `v0.6.0`, /// `arc_consensus:v0.5.1-rc1`, or `0.7.0`. Returns `None` for missing tags, /// the `"latest"` tag, or tags that do not fit the `MAJOR.MINOR.PATCH[-...]` -/// pattern. Callers decide how to interpret `None` — [`check_cli_version`] -/// distinguishes `latest` from unparsable, while [`apply_version_compat`] -/// treats every `None` uniformly as "assume the target supports every flag". +/// pattern. [`apply_version_compat`] treats every `None` uniformly as +/// "assume the target supports every flag". fn parse_image_semver(image_tag: Option<&str>) -> Option<(u64, u64, u64)> { let tag = image_tag?; let version_str = tag.rsplit(':').next().unwrap_or(tag); @@ -214,39 +178,43 @@ mod tests { use super::*; #[test] - fn supports_cli_flags_returns_true_for_latest() { - assert!(supports_cli_flags(Some("arc_consensus:latest"))); - assert!(supports_cli_flags(Some("latest"))); - } - - #[test] - fn supports_cli_flags_returns_true_for_new_versions() { - assert!(supports_cli_flags(Some("arc_consensus:v0.5.0"))); - assert!(supports_cli_flags(Some("arc_consensus:v0.6.0"))); - assert!(supports_cli_flags(Some("arc_consensus:v1.0.0"))); - assert!(supports_cli_flags(Some("v0.5.0"))); - assert!(supports_cli_flags(Some("0.5.0"))); - } - - #[test] - fn supports_cli_flags_returns_false_for_old_versions() { - assert!(!supports_cli_flags(Some("arc_consensus:v0.4.0"))); - assert!(!supports_cli_flags(Some("arc_consensus:v0.4.1"))); - assert!(!supports_cli_flags(Some("arc_consensus:v0.3.0"))); - assert!(!supports_cli_flags(Some("v0.4.0"))); - assert!(!supports_cli_flags(Some("0.4.0"))); - } - - #[test] - fn supports_cli_flags_returns_true_for_none() { - assert!(supports_cli_flags(None)); + fn ensure_cl_image_supported_accepts_current_and_unparsable_tags() { + for tag in [ + None, + Some("arc_consensus:latest"), + Some("latest"), + Some("arc_consensus:v0.5.0"), + Some("v0.5.0"), + Some("0.5.0"), + Some("arc_consensus:v0.6.0"), + Some("arc_consensus:v1.0.0"), + Some("v0.5.0-rc1"), + Some("arc_consensus:abc123"), + ] { + assert!( + ensure_cl_image_supported(tag).is_ok(), + "tag {tag:?} should be accepted" + ); + } } #[test] - fn supports_cli_flags_handles_prerelease_versions() { - assert!(supports_cli_flags(Some("v0.5.0-rc1"))); - assert!(supports_cli_flags(Some("v0.5.0-beta"))); - assert!(!supports_cli_flags(Some("v0.4.0-rc1"))); + fn ensure_cl_image_supported_rejects_pre_v0_5_0() { + for tag in [ + "arc_consensus:v0.4.0", + "arc_consensus:v0.4.1", + "arc_consensus:v0.3.0", + "v0.4.0", + "0.4.0", + "v0.4.0-rc1", + ] { + let err = ensure_cl_image_supported(Some(tag)) + .expect_err(&format!("tag {tag} should be rejected")); + assert!( + err.to_string().contains("predates v0.5.0"), + "unexpected error for {tag}: {err}" + ); + } } /// Run `apply_version_compat` over `input` for the given `image_tag` and diff --git a/crates/quake/src/infra/docker.rs b/crates/quake/src/infra/docker.rs index 76cfc0ae..c429bc94 100644 --- a/crates/quake/src/infra/docker.rs +++ b/crates/quake/src/infra/docker.rs @@ -18,7 +18,6 @@ use color_eyre::eyre::{bail, Context, Result}; use std::path::Path; use crate::shell; -use crate::testnet::DockerImages; /// Check if the docker compose file exists pub(crate) fn compose_file_exists(compose_path: &Path) -> Result<()> { @@ -97,8 +96,8 @@ pub(crate) fn pull(image: &str) -> Result<()> { /// Check if the given Docker images exist in the local Docker image store. /// Returns an error if any image does not exist. -pub(crate) fn images_exist(images: &DockerImages) -> Result<()> { - for tag in &images.all() { +pub(crate) fn images_exist(images: &[String]) -> Result<()> { + for tag in images { let filter = format!("reference={tag}"); let args = vec![ "images", diff --git a/crates/quake/src/infra/exec.rs b/crates/quake/src/infra/exec.rs new file mode 100644 index 00000000..89f309f4 --- /dev/null +++ b/crates/quake/src/infra/exec.rs @@ -0,0 +1,90 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Cheaply-cloneable handle for executing a command inside a node's CL or EL +//! container. Carries only what `docker exec` actually needs (a directory plus +//! shared node metadata for local, an `Arc` for remote), so each +//! clone can be moved into a `tokio::task::spawn_blocking` worker for concurrent +//! fan-out. + +use std::path::PathBuf; +use std::sync::Arc; + +use color_eyre::eyre::{bail, Result}; + +use crate::infra::remote::{RemoteInfra, CONTAINER_NAME_CONSENSUS, CONTAINER_NAME_EXECUTION}; +use crate::infra::InfraType; +use crate::node::{NodeName, CONSENSUS_SUFFIX, EXECUTION_SUFFIX}; +use crate::nodes::NodesMetadata; +use crate::shell; +use crate::testnet::Testnet; + +#[derive(Clone)] +pub(crate) enum ExecBackend { + Local { + dir: PathBuf, + nodes: Arc, + }, + Remote(Arc), +} + +impl From<&Testnet> for ExecBackend { + fn from(testnet: &Testnet) -> Self { + match testnet.infra_data.infra_type { + InfraType::Local => Self::Local { + dir: testnet.dir.clone(), + nodes: Arc::new(testnet.nodes_metadata.clone()), + }, + InfraType::Remote => Self::Remote( + testnet + .remote_infra() + .expect("infra_type Remote implies RemoteInfra"), + ), + } + } +} + +impl ExecBackend { + /// Execute a command inside a node's CL or EL container and return its stdout. + /// + /// Local: `docker exec `. + /// Remote: SSH to the node's EC2 host (routed through CC), then run + /// `docker exec ` because each remote host runs only one node. + pub fn exec_in_container( + &self, + node: &NodeName, + container: &str, + argv: &[&str], + ) -> Result { + match self { + Self::Local { dir, nodes } => { + let container = nodes.running_container_name(node, container)?; + let mut args = vec!["exec", container.as_str()]; + args.extend_from_slice(argv); + shell::exec_with_output("docker", args, dir) + } + Self::Remote(remote) => { + let container = match container { + CONSENSUS_SUFFIX => CONTAINER_NAME_CONSENSUS, + EXECUTION_SUFFIX => CONTAINER_NAME_EXECUTION, + _ => bail!("unsupported container suffix '{container}'"), + }; + let remote_cmd = format!("docker exec {container} {}", argv.join(" ")); + remote.ssh_node_with_output(node, &remote_cmd) + } + } + } +} diff --git a/crates/quake/src/infra/mod.rs b/crates/quake/src/infra/mod.rs index e1be2214..c8cfa49b 100644 --- a/crates/quake/src/infra/mod.rs +++ b/crates/quake/src/infra/mod.rs @@ -28,6 +28,7 @@ use crate::node::{Container, ContainerName, IpAddress, NodeName, SubnetName}; use crate::nodes::NodeOrContainerName; pub(crate) mod docker; +pub(crate) mod exec; pub(crate) mod export; pub(crate) mod local; pub(crate) mod remote; diff --git a/crates/quake/src/infra/remote.rs b/crates/quake/src/infra/remote.rs index 2252fc39..f474d649 100644 --- a/crates/quake/src/infra/remote.rs +++ b/crates/quake/src/infra/remote.rs @@ -111,6 +111,22 @@ impl RemoteInfra { .wrap_err_with(|| format!("SSH to CC failed for command: {cmd}")) } + /// Same as [`Self::ssh_cc_with_output`], but streams the remote command's + /// stdout/stderr line-by-line through the runner's tracing as it arrives. + /// Use for long-lived commands where buffering until completion would hide + /// what the remote process is currently doing. + pub fn ssh_cc_with_streaming_output(&self, cmd: &str, prefix: &str) -> Result { + shell::ssh_with_streaming_output( + &self.instance_id(CC_INSTANCE)?, + USER_NAME, + &self.private_key_path(), + &self.root_dir, + cmd, + prefix, + ) + .wrap_err_with(|| format!("SSH to CC failed for command: {cmd}")) + } + /// SSH a command to a node. If no command is provided, an interactive shell will be opened. /// /// Routes through CC using the node's private IP to avoid creating additional SSM sessions. @@ -516,7 +532,7 @@ impl RemoteInfra { } /// Copy a file from the CC home directory to a local path. - fn scp_from_cc(&self, remote_source: &str, local_dest: &Path) -> Result<()> { + pub(crate) fn scp_from_cc(&self, remote_source: &str, local_dest: &Path) -> Result<()> { shell::scp_from( &self.instance_id(CC_INSTANCE)?, USER_NAME, @@ -527,82 +543,6 @@ impl RemoteInfra { ) } - /// Download Prometheus metrics from CC via the query_range REST API. - /// - /// Runs `download-metrics.sh` on CC, then SCPs the resulting archive to `local_dest`. - /// `metric_names` filters which metrics are fetched; empty means all. - /// `from`/`to` are Unix timestamps; when omitted the script defaults to epoch→now. - pub fn download_metrics( - &self, - metric_names: &[String], - from: Option, - to: Option, - step: Option<&str>, - local_dest: &Path, - ) -> Result<()> { - let mut cmd = String::from("./download-metrics.sh"); - if let Some(start) = from { - cmd.push_str(&format!(" -s {start}")); - } - if let Some(end) = to { - cmd.push_str(&format!(" -e {end}")); - } - if let Some(s) = step { - cmd.push_str(&format!(" -t {s}")); - } - for name in metric_names { - if !name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':') - { - return Err(color_eyre::eyre::eyre!( - "Invalid metric name '{name}': must match [a-zA-Z0-9_:]+" - )); - } - cmd.push_str(&format!(" {name}")); - } - - info!("📊 Querying Prometheus metrics on CC..."); - let output = self - .ssh_cc_with_output(&cmd) - .wrap_err("Failed to collect metrics on CC")?; - let last_line = output.lines().last().unwrap_or_default().trim(); - let result: serde_json::Value = serde_json::from_str(last_line) - .wrap_err("Failed to parse download-metrics.sh output")?; - let archive = result["archive"] - .as_str() - .ok_or_else(|| eyre!("missing 'archive' field in download-metrics.sh output"))?; - if archive.is_empty() { - let errors = result["errors"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|e| e.as_str()) - .collect::>() - .join("; ") - }) - .unwrap_or_default(); - return Err(eyre!("download-metrics.sh failed: {errors}")); - } - if let Some(errors) = result["errors"].as_array() { - for err in errors { - warn!("metric query failed: {}", err.as_str().unwrap_or("unknown")); - } - } - - let local_dest_abs = self.abs_local_path(local_dest); - info!("⬇️ Downloading metrics archive..."); - self.scp_from_cc(archive, &local_dest_abs) - .wrap_err("Failed to download metrics archive")?; - - if let Err(err) = self.ssh_cc_with_output(&format!("rm ~/{archive}")) { - warn!("⚠️ Failed to clean up temp archive on CC: {err:#}"); - } - - info!(path=%local_dest_abs.display(), "✅ Metrics downloaded"); - Ok(()) - } - /// Download node databases from one or more nodes via CC. /// /// Runs `download-db.sh` on CC, which archives each node's data in parallel and @@ -662,6 +602,10 @@ impl RemoteInfra { } let local_dest_abs = self.abs_local_path(local_dest); + if let Some(parent) = local_dest_abs.parent() { + std::fs::create_dir_all(parent) + .wrap_err_with(|| format!("create output dir {}", parent.display()))?; + } info!("⬇️ Downloading db archive..."); self.scp_from_cc(archive, &local_dest_abs) .wrap_err("Failed to download db archive")?; diff --git a/crates/quake/src/infra/ssm.rs b/crates/quake/src/infra/ssm.rs index 33bbdd63..cd17f123 100644 --- a/crates/quake/src/infra/ssm.rs +++ b/crates/quake/src/infra/ssm.rs @@ -34,6 +34,10 @@ use crate::util::in_parallel; /// Local file that stores the SSM owner ID for one imported remote testnet. pub(crate) const OWNER_ID_FILENAME: &str = ".ssm-owner-id"; +/// AWS closes idle SSM sessions after 20 minutes. Pinging every 5 minutes +/// gives generous headroom for transient network blips. +const KEEP_ALIVE_PING_INTERVAL: Duration = Duration::from_secs(5 * 60); + /// Each developer needs a stable local owner ID so Quake only matches /// and terminates the SSM tunnels created by this machine. pub(crate) fn ensure_owner_id(testnet_dir: &Path) -> Result { @@ -175,6 +179,67 @@ impl Ssm { Ok(()) } + /// Opens a short-lived TCP connection through each tunnel periodically + /// so AWS doesn't close the underlying SSM sessions after 20 minutes of + /// inactivity. The TCP handshake traverses the SSM channel and resets + /// the idle timer; no real data is exchanged. + pub async fn keep_alive(&self, total_duration: Duration) -> Result<()> { + if self.sessions.is_empty() { + info!("No SSM tunnels configured; nothing to keep alive"); + return Ok(()); + } + + let deadline = Instant::now() + total_duration; + info!( + duration = %humantime::format_duration(total_duration), + ping_interval = %humantime::format_duration(KEEP_ALIVE_PING_INTERVAL), + tunnels = self.sessions.len(), + "Keeping SSM tunnels alive (Ctrl-C to stop)" + ); + + loop { + self.ping_sessions(); + + let now = Instant::now(); + if now >= deadline { + info!("✅ Keep-alive period elapsed"); + return Ok(()); + } + let sleep_for = (deadline - now).min(KEEP_ALIVE_PING_INTERVAL); + tokio::select! { + _ = tokio::time::sleep(sleep_for) => {} + _ = tokio::signal::ctrl_c() => { + info!("✅ Keep-alive interrupted (Ctrl-C); SSM tunnels still up"); + return Ok(()); + } + } + } + } + + /// Touches every tunnel's local port to push activity through the SSM + /// channel. Warns on tunnels that no longer have a local listener — they + /// should be repaired with `quake remote ssm start`. + fn ping_sessions(&self) { + let mut alive = 0; + let mut dead = Vec::new(); + for session in &self.sessions { + if self.backend.is_local_port_listening(session.local_port) { + alive += 1; + } else { + dead.push(session.label()); + } + } + if dead.is_empty() { + debug!(alive, "Pinged SSM tunnels"); + } else { + warn!( + alive, + dead = %dead.join(", "), + "Some SSM tunnels have no local listener; run `quake remote ssm start` to repair", + ); + } + } + /// Without cleanup, AWS can still show this machine's old sessions for /// tunnels that no longer help the user. pub async fn stop(&self) -> Result<()> { @@ -635,6 +700,9 @@ mod tests { terminated_sessions: Mutex>, /// `next_id` keeps the counter used to mint fake AWS session IDs. next_id: Mutex, + /// Counts how many times each local port was probed by + /// `is_local_port_listening`. Used to verify keep-alive activity. + port_probes: Mutex>, } impl MockSsmBackend { @@ -677,6 +745,14 @@ mod tests { .expect("terminated_sessions mutex poisoned") .clone() } + + /// Ports probed by `is_local_port_listening`, in call order. + fn port_probes(&self) -> Vec { + self.port_probes + .lock() + .expect("port_probes mutex poisoned") + .clone() + } } impl SsmBackend for MockSsmBackend { @@ -689,6 +765,10 @@ mod tests { } fn is_local_port_listening(&self, local_port: u16) -> bool { + self.port_probes + .lock() + .expect("port_probes mutex poisoned") + .push(local_port); self.listening_ports .lock() .expect("listening_ports mutex poisoned") @@ -1007,4 +1087,76 @@ session-3 Failed quake-cc-i-123-8000-80 assert!(rendered.contains("aws: []")); assert!(!rendered.contains("foreign-1")); } + + #[tokio::test] + async fn keep_alive_probes_every_tunnel_then_returns() { + let session_a = test_session(13000, 3000); + let session_b = test_session(19090, 9090); + let backend = Arc::new(MockSsmBackend::default()); + backend.listen_on(session_a.local_port); + backend.listen_on(session_b.local_port); + let ssm = Ssm::with_backend(vec![session_a.clone(), session_b.clone()], backend.clone()); + + // Zero duration: one ping pass, then the deadline check exits. + ssm.keep_alive(Duration::ZERO) + .await + .expect("keep_alive succeeds"); + + assert_eq!( + backend.port_probes(), + vec![session_a.local_port, session_b.local_port], + ); + } + + #[tokio::test] + async fn keep_alive_with_no_sessions_is_noop() { + let backend = Arc::new(MockSsmBackend::default()); + let ssm = Ssm::with_backend(vec![], backend.clone()); + + ssm.keep_alive(Duration::from_secs(60)) + .await + .expect("keep_alive succeeds"); + + assert!(backend.port_probes().is_empty()); + } + + #[tokio::test] + async fn keep_alive_pings_multiple_times_within_window() { + let session = test_session(13000, 3000); + let backend = Arc::new(MockSsmBackend::default()); + backend.listen_on(session.local_port); + let ssm = Ssm::with_backend(vec![session.clone()], backend.clone()); + + // Non-zero duration: the loop pings, sleeps, then pings again before + // the deadline check exits. Exercises the tokio::select! sleep arm + // that Duration::ZERO skips. + ssm.keep_alive(Duration::from_millis(50)) + .await + .expect("keep_alive succeeds"); + + let probes = backend.port_probes(); + assert!( + probes.len() >= 2, + "expected at least 2 ping passes within 50ms; got {}", + probes.len() + ); + assert!( + probes.iter().all(|p| *p == session.local_port), + "every probe should target {} but got {probes:?}", + session.local_port, + ); + } + + #[tokio::test] + async fn keep_alive_tolerates_tunnels_with_no_local_listener() { + let session = test_session(13000, 3000); + let backend = Arc::new(MockSsmBackend::default()); + let ssm = Ssm::with_backend(vec![session.clone()], backend.clone()); + + ssm.keep_alive(Duration::ZERO) + .await + .expect("keep_alive succeeds even when tunnel is down"); + + assert_eq!(backend.port_probes(), vec![session.local_port]); + } } diff --git a/crates/quake/src/infra/terraform.rs b/crates/quake/src/infra/terraform.rs index 3b7178d0..298290a1 100644 --- a/crates/quake/src/infra/terraform.rs +++ b/crates/quake/src/infra/terraform.rs @@ -100,6 +100,8 @@ impl Terraform { /// Terraform leaves the AMI default root volume size. /// `node_volume_type` and `node_volume_iops` configure the node root EBS volume type /// and provisioned IOPS; when omitted, Terraform falls back to `gp3` at the AMI default IOPS. + /// `node_data_on_instance_store` relocates the node data directory onto the local + /// instance-store NVMe; a no-op on instance types without instance store. #[allow(clippy::too_many_arguments)] pub(crate) fn create( &self, @@ -111,6 +113,7 @@ impl Terraform { cc_disk_gb: Option, node_volume_type: Option<&str>, node_volume_iops: Option, + node_data_on_instance_store: bool, ) -> Result<()> { // Ensure testnet directory exists if !dry_run { @@ -128,6 +131,7 @@ impl Terraform { cc_disk_gb, node_volume_type, node_volume_iops, + node_data_on_instance_store, )?; args.extend(vars.iter().map(String::as_str)); @@ -149,7 +153,8 @@ impl Terraform { pub(crate) fn destroy(&self, yes: bool) -> Result<()> { let mut args: Vec<&str> = vec!["destroy"]; - let vars = self.build_variables(&self.node_names, None, None, None, None, None, None)?; + let vars = + self.build_variables(&self.node_names, None, None, None, None, None, None, false)?; args.extend(vars.iter().map(String::as_str)); let state_flag = self.state_flag(); @@ -212,6 +217,7 @@ impl Terraform { cc_disk_gb: Option, node_volume_type: Option<&str>, node_volume_iops: Option, + node_data_on_instance_store: bool, ) -> Result> { let mut args: Vec = Vec::new(); @@ -222,6 +228,9 @@ impl Terraform { args.push("-var".to_string()); args.push(format!("manifest_path={}", self.manifest_file.display())); + // Global images only, used for a cloud-init warmup `docker pull`. + // Mixed-version networks still pull each node's real image at `docker + // compose up` via the per-node compose file. args.push("-var".to_string()); args.push(format!("image_cl={}", self.images.cl)); args.push("-var".to_string()); @@ -302,6 +311,12 @@ impl Terraform { args.push(format!("node_volume_iops={iops}")); } + // Only push when enabled; the Terraform variable defaults to false. + if node_data_on_instance_store { + args.push("-var".to_string()); + args.push("node_data_on_instance_store=true".to_string()); + } + Ok(args) } } diff --git a/crates/quake/src/latency.rs b/crates/quake/src/latency.rs index 5daa63e2..cb873312 100644 --- a/crates/quake/src/latency.rs +++ b/crates/quake/src/latency.rs @@ -14,13 +14,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -use color_eyre::eyre::{bail, Context, OptionExt, Result}; +use color_eyre::eyre::{bail, eyre, Context, OptionExt, Result}; use indexmap::IndexMap; use rand::rngs::StdRng; use rand::seq::SliceRandom; use rand::SeedableRng; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::Path; @@ -117,7 +117,7 @@ impl Region { } /// Get index for latency matrix - fn index(&self) -> usize { + pub(crate) fn index(&self) -> usize { *self as usize } @@ -408,3 +408,218 @@ fn assign_regions( Ok(node_regions) } + +/// Read `region_assignments.json` from a testnet's directory and parse each +/// region into a [`Region`]. Used by readiness checks that need to know which +/// region every node was assigned. +pub(crate) fn load_region_assignments(testnet_dir: &Path) -> Result> { + let path = testnet_dir.join(REGION_ASSIGNMENTS_FILENAME); + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(IndexMap::new()), + Err(e) => { + return Err(e).with_context(|| format!("Failed to read {}", path.display())); + } + }; + let parsed: IndexMap = serde_json::from_str(&raw) + .with_context(|| format!("Failed to parse {}", path.display()))?; + let mut out = IndexMap::new(); + for (node, region) in parsed { + let region = Region::from_str(®ion).map_err(|_| { + eyre!( + "invalid region '{region}' for node '{node}' in {}", + path.display() + ) + })?; + out.insert(node, region); + } + Ok(out) +} + +/// For each node, build `peer_ip_hex → expected_delay_ms`. +pub(crate) fn build_expected_delays( + testnet_dir: &Path, + nodes_metadata: &NodesMetadata, +) -> Result>> { + let regions = &load_region_assignments(testnet_dir)?; + + let mut out = HashMap::new(); + for (node, region) in regions { + let mut map = HashMap::new(); + for (peer, peer_region) in regions { + if peer == node { + continue; + } + let delay = AWS_LATENCY_MATRIX[region.index()][peer_region.index()]; + if delay == 0 { + continue; + } + let mut ips = nodes_metadata.get_consensus_ip_addresses(peer); + ips.extend(nodes_metadata.get_execution_ip_addresses(peer)); + for ip in ips { + if let Ok(hex) = ipv4_to_hex(&ip) { + map.insert(hex, delay); + } + } + } + out.insert(node.clone(), map); + } + Ok(out) +} + +// ── Parsers ──────────────────────────────────────────────────────────── + +/// Extract `handle → base_delay_ms` from `tc qdisc show` output. +/// +/// Sample line we match: +/// `qdisc netem 11: parent 1:11 limit 1000 delay 35ms 1750us 5%`. +pub(crate) fn parse_netem_qdiscs(out: &str) -> BTreeMap { + let mut result = BTreeMap::new(); + for line in out.lines() { + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.first() != Some(&"qdisc") || tokens.get(1) != Some(&"netem") { + continue; + } + let Some(handle_tok) = tokens.get(2) else { + continue; + }; + let handle = handle_tok.trim_end_matches(':').to_string(); + if let Some(idx) = tokens.iter().position(|t| *t == "delay") { + if let Some(delay_ms) = tokens.get(idx + 1).and_then(parse_delay_ms) { + result.insert(handle, delay_ms); + } + } + } + result +} + +/// Parse a `delay ms` token. Accepts integer `35ms`; rejects unit-less or +/// non-`ms` forms so `1750us` (the jitter token) doesn't get misread as 1750ms. +pub(crate) fn parse_delay_ms(token: &&str) -> Option { + token.strip_suffix("ms").and_then(|n| n.parse::().ok()) +} + +/// Extract `peer_ip_hex → handle` from `tc filter show` output. +/// +/// `flowid 1:` appears on the filter's leader line; the IP is on the +/// following `match` continuation line as `/ at 16`. +pub(crate) fn parse_u32_filters(out: &str) -> HashMap { + let mut result = HashMap::new(); + let mut current_handle: Option = None; + for line in out.lines() { + let trimmed = line.trim(); + if let Some(idx) = trimmed.find("flowid 1:") { + let rest = &trimmed[idx + "flowid 1:".len()..]; + let handle: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if !handle.is_empty() { + current_handle = Some(handle); + } + } + if let Some(rest) = trimmed.strip_prefix("match ") { + let mut tokens = rest.split_whitespace(); + let token = tokens.next().unwrap_or(""); + if tokens.next() != Some("at") || tokens.next() != Some("16") { + continue; + } + if let Some((hex, _mask)) = token.split_once('/') { + if let Some(handle) = current_handle.as_ref() { + result.insert(hex.to_string(), handle.clone()); + } + } + } + } + result +} + +/// Convert `"10.0.0.5"` → `"0a000005"` (lowercase hex, big-endian). +fn ipv4_to_hex(ip: &str) -> Result { + let octets: Vec = ip + .split('.') + .map(|s| { + s.parse::() + .map_err(|_| eyre!("invalid ip octet in {ip}")) + }) + .collect::>>()?; + if octets.len() != 4 { + return Err(eyre!("ip {ip} is not v4")); + } + Ok(format!( + "{:02x}{:02x}{:02x}{:02x}", + octets[0], octets[1], octets[2], octets[3] + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ipv4_to_hex_basic() { + assert_eq!(ipv4_to_hex("10.0.0.5").unwrap(), "0a000005"); + assert_eq!(ipv4_to_hex("172.19.1.2").unwrap(), "ac130102"); + assert_eq!(ipv4_to_hex("255.255.255.255").unwrap(), "ffffffff"); + } + + #[test] + fn ipv4_to_hex_rejects_bad_input() { + assert!(ipv4_to_hex("not.an.ip.address").is_err()); + assert!(ipv4_to_hex("1.2.3").is_err()); + assert!(ipv4_to_hex("1.2.3.4.5").is_err()); + } + + #[test] + fn parse_netem_qdiscs_finds_netem_and_skips_others() { + let out = + "qdisc htb 1: root refcnt 2 r2q 10 default 0x10 direct_packets_stat 0 direct_qlen 1000 +qdisc sfq 10: parent 1:10 limit 127p quantum 1514b depth 127 divisor 1024 perturb 10sec +qdisc netem 11: parent 1:11 limit 1000 delay 35ms 1750us +qdisc netem 12: parent 1:12 limit 1000 delay 50ms 2ms +qdisc netem 13: parent 1:13 limit 1000 delay 120ms +"; + let map = parse_netem_qdiscs(out); + assert_eq!(map.get("11"), Some(&35)); + assert_eq!(map.get("12"), Some(&50)); + assert_eq!(map.get("13"), Some(&120)); + assert_eq!(map.len(), 3); + } + + #[test] + fn parse_netem_qdiscs_returns_empty_on_default_qdisc() { + let out = + "qdisc pfifo_fast 0: root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1\n"; + assert!(parse_netem_qdiscs(out).is_empty()); + } + + #[test] + fn parse_u32_filters_maps_ip_to_handle() { + let out = "filter parent 1: protocol ip pref 1 u32 chain 0 +filter parent 1: protocol ip pref 1 u32 chain 0 fh 800: ht divisor 1 +filter parent 1: protocol ip pref 1 u32 chain 0 fh 800::800 order 2048 key ht 800 bkt 0 flowid 1:11 not_in_hw + match 0a000005/ffffffff at 16 +filter parent 1: protocol ip pref 1 u32 chain 0 fh 800::801 order 2049 key ht 800 bkt 0 flowid 1:12 not_in_hw + match ac130102/ffffffff at 16 +"; + let map = parse_u32_filters(out); + assert_eq!(map.get("0a000005"), Some(&"11".to_string())); + assert_eq!(map.get("ac130102"), Some(&"12".to_string())); + assert_eq!(map.len(), 2); + } + + #[test] + fn parse_u32_filters_ignores_non_destination_matches() { + let out = "filter parent 1: protocol ip pref 1 u32 chain 0 +filter parent 1: protocol ip pref 1 u32 chain 0 fh 800::800 order 2048 key ht 800 bkt 0 flowid 1:11 not_in_hw + match 0a000001/ffffffff at 12 + match 0a000005/ffffffff at 16 +"; + let map = parse_u32_filters(out); + assert_eq!(map.get("0a000005"), Some(&"11".to_string())); + assert!(!map.contains_key("0a000001")); + assert_eq!(map.len(), 1); + } + + #[test] + fn parse_u32_filters_returns_empty_when_no_filters() { + assert!(parse_u32_filters("").is_empty()); + } +} diff --git a/crates/quake/src/load.rs b/crates/quake/src/load.rs index 8115a4a5..14084f8e 100644 --- a/crates/quake/src/load.rs +++ b/crates/quake/src/load.rs @@ -30,7 +30,8 @@ use spammer::{self, Spammer, SpammerArgs}; /// /// `fire_and_forget` controls whether the generated transactions wait for /// receipts (`false` for `load`, `true` for `spam`). `silent` propagates the -/// top-level verbosity flag to the local spammer's config. +/// top-level verbosity flag to the spammer — to the local config directly, and +/// to the remote spammer as a `--silent` flag. pub(crate) async fn run( testnet: &Testnet, target_nodes: Vec, @@ -44,7 +45,7 @@ pub(crate) async fn run( config.validate()?; load(testnet, target_nodes, &config).await } - InfraType::Remote => load_remote(testnet, target_nodes, args, fire_and_forget), + InfraType::Remote => load_remote(testnet, target_nodes, args, fire_and_forget, silent), } } @@ -93,6 +94,7 @@ pub(crate) fn load_remote( target_nodes: Vec, args: &SpammerArgs, fire_and_forget: bool, + silent: bool, ) -> Result<()> { let infra = testnet.remote_infra()?; if args.csv_dir.is_some() { @@ -101,7 +103,9 @@ pub(crate) fn load_remote( latency CSV would land on the inaccessible Control Center." ); } - let mut cli_args = args.to_cli_args(); + let mut spammer_args = args.clone(); + spammer_args.silent = spammer_args.silent || silent; + let mut cli_args = spammer_args.to_cli_args(); if !target_nodes.is_empty() { cli_args.push("--targets".to_string()); cli_args.push(target_nodes.join(",")); @@ -118,7 +122,10 @@ pub(crate) fn load_remote( /// /// Explicit selectors must resolve to at least one node. Load generation /// against an empty target set is treated as an error. -fn resolve_load_target_nodes(manifest: &Manifest, selectors: &[NodeName]) -> Result> { +pub(crate) fn resolve_load_target_nodes( + manifest: &Manifest, + selectors: &[NodeName], +) -> Result> { if selectors.is_empty() { return Ok(manifest.nodes.keys().cloned().collect()); } diff --git a/crates/quake/src/main.rs b/crates/quake/src/main.rs index 3fc79c51..2985604a 100644 --- a/crates/quake/src/main.rs +++ b/crates/quake/src/main.rs @@ -51,6 +51,7 @@ mod load; mod manifest; mod mcp; mod mesh; +mod metrics; mod monitor; mod node; mod nodekey; @@ -58,6 +59,8 @@ mod nodes; mod perturb; mod report; mod rpc; +mod rpc_cmd; +mod run; mod setup; mod shell; mod testnet; @@ -183,6 +186,14 @@ enum Commands { #[command(subcommand)] command: MonitoringSubcommand, }, + /// Download monitoring data (metrics and/or node databases). + /// + /// Without a subcommand, downloads both metrics and a single-node database + /// snapshot using defaults. Works in both local and remote mode. + Download { + #[command(subcommand)] + command: Option, + }, /// Send transaction load to the testnet (backpressure mode: waits for each /// response and only advances the nonce on success). /// Use --mix to blend transaction types (e.g., --mix transfer=70,erc20=30). @@ -231,13 +242,30 @@ enum Commands { #[clap(subcommand)] command: WaitSubcommand, }, + /// Send a JSON-RPC call (EL) or REST call (CL) to one or more nodes. + /// + /// `el` targets Reth's JSON-RPC; `cl` targets Malachite's REST API. + /// `list` shows the live CL endpoint catalog plus a link to the Reth docs. + /// + /// Examples: + /// quake rpc list + /// quake rpc el admin_clearTxpool + /// quake rpc el eth_blockNumber ALL_VALIDATORS + /// quake rpc el eth_getBalance validator1 0xabc... latest + /// quake rpc cl /status + /// quake rpc cl /commit ALL_VALIDATORS + #[command(verbatim_doc_comment)] + Rpc { + #[command(subcommand)] + command: RpcSubcommand, + }, /// Run tests against the testnet (or list with --dry-run) /// /// Supports glob patterns (* and ?) for matching groups and tests. /// IMPORTANT: Quote patterns to prevent shell expansion, e.g., 'n*:*peer*' /// /// Examples: - /// quake test - Run all tests (except excluded groups: validation, health, validator_set, perf) + /// quake test - Run all tests (except excluded groups: validation, health, validator_set, perf, infra) /// quake test probe - Run all tests in probe group /// quake test 'n*' - Run tests in groups starting with n /// quake test 'n*:*peer*' - Run tests containing 'peer' in groups starting with n @@ -260,6 +288,17 @@ enum Commands { #[clap(long = "set", value_parser = parse_key_value)] params: Vec<(String, String)>, }, + /// Run a test scenario against an already-running testnet. + /// + /// Each subcommand executes one scenario and writes its artifacts under + /// the configured output directory. The testnet must be started + /// separately (e.g. with `quake start --remote`) and is left running + /// after the scenario completes. + #[command(verbatim_doc_comment)] + Run { + #[command(subcommand)] + command: run::RunSubcommand, + }, /// Generate random manifests /// /// This command generates multiple random manifests with different seeds. @@ -360,16 +399,8 @@ enum Commands { }, /// Start an MCP (Model Context Protocol) server for AI-assisted testnet management. /// - /// By default uses stdio transport (for Claude Code, Cursor, etc.). - /// Use --http to start an HTTP+SSE server for remote clients. - Mcp { - /// Use HTTP+SSE transport instead of stdio - #[clap(long, default_value = "false")] - http: bool, - /// Port for HTTP+SSE server (only used with --http) - #[clap(long, default_value = "8080")] - port: u16, - }, + /// Uses stdio transport for Claude Code, Cursor, and similar MCP clients. + Mcp, } #[derive(Args)] @@ -467,6 +498,14 @@ pub(crate) struct InfraArgs { /// violations (per-type maxima, volume-size ratio) at apply time. #[clap(long, value_name = "IOPS", value_parser = clap::value_parser!(u32).range(100..=256_000))] node_volume_iops: Option, + /// Place the node data directory on the local instance-store NVMe instead of the root EBS volume. + /// + /// Requires an instance type that ships local NVMe instance storage (e.g. `i4i.*`, `i3.*`, + /// `m6id.*`); pair with `--node-size`. On instance types without instance store this is a + /// no-op and the data directory stays on the root EBS volume. Independent of + /// `--node-volume-type`/`--node-volume-iops`, which keep configuring the root EBS volume. + #[clap(long)] + node_data_on_instance_store: bool, } #[derive(Args)] @@ -498,6 +537,33 @@ impl CleanArgs { } } +#[derive(Subcommand)] +pub(crate) enum RpcSubcommand { + /// List available endpoints: CL live catalog + EL docs link. + List, + /// Send a JSON-RPC request to the Execution Layer (Reth). + /// + /// Examples: + /// quake rpc el admin_clearTxpool + /// quake rpc el eth_blockNumber ALL_VALIDATORS + /// quake rpc el eth_getBalance validator1 0xabc... latest + /// quake rpc el eth_call validator1 --raw '[{"to":"0x..."},"latest"]' + #[command(verbatim_doc_comment)] + El(rpc_cmd::ElArgs), + /// Send a REST request to the Consensus Layer (Malachite). + /// + /// The leading `/` on the path is optional. + /// + /// Examples: + /// quake rpc cl /status + /// quake rpc cl consensus-state + /// quake rpc cl /commit ALL_VALIDATORS + /// quake rpc cl '/commit?height=42' validator1 + /// quake rpc cl /persistent-peers validator1 --method POST --body '{"addr":"/ip4/..."}' + #[command(verbatim_doc_comment)] + Cl(rpc_cmd::ClArgs), +} + #[derive(Debug, Subcommand, PartialEq)] pub(crate) enum WaitSubcommand { /// Wait for nodes to reach a specific block height @@ -682,7 +748,10 @@ pub(crate) enum RemoteSubcommand { /// Path to the JSON file created by `quake remote export` path: PathBuf, }, - /// Download metrics or database data from remote infrastructure + /// [DEPRECATED] Download metrics or database data from remote infrastructure. + /// + /// Use `quake download {metrics,db}` instead — it works in both + /// local and remote mode. Download { #[clap(subcommand)] command: DownloadSubcommand, @@ -699,6 +768,58 @@ pub(crate) enum MonitoringSubcommand { Clean, } +#[derive(Debug, Subcommand)] +pub(crate) enum DownloadKindSubcommand { + /// Download a Prometheus metrics snapshot for the testnet. + /// + /// Queries the `query_range` API directly over HTTP (local Docker port for + /// local testnets, SSM-tunnelled port for remote) and bundles the JSON + /// responses into a single `.tar.gz` under `.quake/metrics//`. + Metrics { + /// Start of the time range (default: headStats.minTime from Prometheus; e.g. 2024-01-15T10:30:00Z or 2024-01-15) + #[clap(long)] + from: Option, + /// End of the time range (default: now; e.g. 2024-01-15T10:30:00Z or 2024-01-15) + #[clap(long)] + to: Option, + /// Query resolution — interval between data points (e.g. 30s, 1m, 5m). + /// Defaults to ceil((end-start)/10000) to stay within Prometheus' 11 000-point limit. + #[clap(long)] + step: Option, + /// Metric names to download (pass after `--`; downloads every metric Prometheus knows about by default) + #[clap(last = true)] + metric_names: Vec, + /// Output file path (default: .quake/metrics//quake-metrics-.tar.gz) + #[clap(short = 'o', long)] + output: Option, + }, + /// Download node database(s). + /// + /// In remote mode: archives DB data from the selected node(s) via the CC and + /// downloads the resulting `.tar.gz` to `.quake/db//`. + /// + /// In local mode: prints the path to the selected node's on-disk DB files; + /// no archiving is performed since they are already on the local filesystem. + /// + /// Defaults to the first node in the manifest (DBs across nodes are typically + /// identical; downloading from all of them is wasteful). Pass node names + /// after `--` to override. + Db { + /// Node names to download from (default: first node in manifest) + #[clap(last = true)] + nodes: Vec, + /// Download only execution layer (Reth) data + #[clap(long, conflicts_with = "consensus_only")] + execution_only: bool, + /// Download only consensus layer (Malachite) data + #[clap(long)] + consensus_only: bool, + /// Output file path (remote only; default: .quake/db//quake-db-.tar.gz) + #[clap(short = 'o', long)] + output: Option, + }, +} + /// A datetime accepted by `--from`/`--to` flags, converted to a Unix timestamp. /// /// Accepted formats (timezone-naive values are treated as UTC): @@ -741,7 +862,8 @@ impl std::str::FromStr for CliTimestamp { pub(crate) enum DownloadSubcommand { /// Download Prometheus metrics from the Control Center. /// - /// SSHes to CC and queries the Prometheus query_range API — no local SSM tunnel required. + /// Alias of `quake metrics download` kept for backward compatibility. + /// Requires the SSM tunnel to be open (start with `quake remote ssm start`). /// Downloads all metrics by default; pass metric names after -- to filter. /// Without --from/--to, start defaults to headStats.minTime (Prometheus head block only, ~2h max). Metrics { @@ -781,8 +903,7 @@ pub(crate) enum DownloadSubcommand { }, } -#[derive(Debug, Subcommand, serde::Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "lowercase")] +#[derive(Debug, Subcommand)] pub(crate) enum SSMSubcommand { /// Start SSM tunnels to the Control Center server Start, @@ -790,10 +911,24 @@ pub(crate) enum SSMSubcommand { Stop, /// List all active SSM tunnels List, + /// Keep all SSM tunnels alive for the given duration + /// + /// AWS closes SSM sessions after 20 minutes of inactivity. This command + /// periodically opens a TCP connection through each tunnel to reset the + /// inactivity timer. Run it in a separate terminal alongside long + /// experiments. Cancel with Ctrl-C when no longer needed. + #[command(verbatim_doc_comment)] + KeepAlive { + /// How long to keep tunnels alive (e.g. "30m", "2h", "1h30m") + #[clap(value_parser = parse_duration)] + duration: Duration, + }, } #[tokio::main] async fn main() -> Result<()> { + reset_sigpipe_to_default(); + dotenvy::dotenv().ok(); let cli = Cli::parse(); @@ -806,7 +941,8 @@ async fn main() -> Result<()> { .add_directive("arc_node_consensus_cli::new=info".parse()?); tracing_subscriber::fmt() .with_env_filter(filter) - .with_ansi(std::io::stdout().is_terminal()) + .with_writer(std::io::stderr) + .with_ansi(std::io::stderr().is_terminal()) .init(); tracing::info!( @@ -946,6 +1082,7 @@ async fn main() -> Result<()> { Commands::Info { command } => testnet.info(command).await?, Commands::Remote { command } => testnet.remote(command).await?, Commands::Monitoring { command } => testnet.monitoring(command).await?, + Commands::Download { command } => testnet.download(command).await?, Commands::Load { targets, args } => { let target_nodes = targets.unwrap_or_default(); load::run( @@ -977,6 +1114,7 @@ async fn main() -> Result<()> { } => { let params = crate::tests::TestParams::from(params); testnet + .with_seed(cli.seed) .run_tests(&spec, dry_run, rpc_timeout, ¶ms) .await? } @@ -1012,6 +1150,27 @@ async fn main() -> Result<()> { .await? } }, + Commands::Rpc { command } => match command { + RpcSubcommand::List => { + let (node, base_url) = testnet.nodes_metadata.first_consensus_rpc_url(); + rpc_cmd::run_list(node, base_url).await? + } + RpcSubcommand::El(args) => { + let (target, _params) = args.parse_positionals()?; + let selectors = parse_comma_separated_string(target.as_deref())?; + let node_urls = testnet + .nodes_metadata + .resolve_el_targets(&testnet.manifest, selectors.as_deref())?; + rpc_cmd::run_el(node_urls, args).await? + } + RpcSubcommand::Cl(args) => { + let selectors = parse_comma_separated_string(args.target.as_deref())?; + let node_urls = testnet + .nodes_metadata + .resolve_cl_targets(&testnet.manifest, selectors.as_deref())?; + rpc_cmd::run_cl(node_urls, args).await? + } + }, Commands::Web { host, port, @@ -1032,17 +1191,42 @@ async fn main() -> Result<()> { ) .await?; } - Commands::Mcp { http, port } => { - crate::mcp::run_server(testnet, http, port).await?; + Commands::Mcp => { + crate::mcp::run_server(testnet).await?; } + Commands::Run { command } => run::dispatch(testnet, command).await?, Commands::Generate { .. } => {} // handled above } Ok(()) } +/// Restore the POSIX default disposition for SIGPIPE so the process exits +/// quietly on broken pipes (e.g. `quake rpc ... | head -1`) instead of +/// panicking from a failed `println!`. Rust installs `SIG_IGN` for SIGPIPE at +/// startup; every well-behaved CLI tool undoes that. +fn reset_sigpipe_to_default() { + #[cfg(unix)] + { + // SIGPIPE = 13 and SIG_DFL = null sighandler on every POSIX target. + const SIGPIPE: i32 = 13; + unsafe extern "C" { + fn signal( + signum: i32, + handler: Option, + ) -> Option; + } + // SAFETY: `signal` is an atomic process-global operation. No other + // code in this process modifies SIGPIPE disposition, so there is no + // race. + unsafe { + signal(SIGPIPE, None); + } + } +} + /// Parse a time duration from a string formatted as a human-readable duration. -fn parse_duration(s: &str) -> Result { +pub(crate) fn parse_duration(s: &str) -> Result { humantime::parse_duration(s).wrap_err_with(|| format!("invalid duration: {s}")) } @@ -1053,6 +1237,24 @@ fn parse_key_value(s: &str) -> Result<(String, String)> { Ok((key.to_string(), value.to_string())) } +/// Split a comma-separated string into a list of strings. +fn parse_comma_separated_string(target: Option<&str>) -> Result>> { + target.map(split_comma_names).transpose() +} + +fn split_comma_names(str: &str) -> Result> { + let names: Vec = str + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if names.is_empty() { + bail!("Empty target node list"); + } + Ok(names) +} + /// Prepare the testnet before starting it /// /// If the remote flag is set, it will create the remote infrastructure and reload the testnet. @@ -1074,12 +1276,13 @@ async fn pre_start( || m.cc_disk_gb.is_some() || m.node_volume_type.is_some() || m.node_volume_iops.is_some() + || m.node_data_on_instance_store { warn!( "Manifest sets remote-only infrastructure fields (node_size/cc_size/\ - node_disk_gb/cc_disk_gb/node_volume_type/node_volume_iops), but these \ - are only applied when creating remote infrastructure (--remote). They \ - are ignored in local mode." + node_disk_gb/cc_disk_gb/node_volume_type/node_volume_iops/\ + node_data_on_instance_store), but these are only applied when creating \ + remote infrastructure (--remote). They are ignored in local mode." ); } } @@ -1111,6 +1314,9 @@ async fn pre_start( .infra_args .node_volume_iops .or(testnet.manifest.node_volume_iops); + // Enabled by either the CLI flag or the manifest field. + let node_data_on_instance_store = args.infra_args.node_data_on_instance_store + || testnet.manifest.node_data_on_instance_store; // CLI overrides can mix freely with manifest fields, so re-validate // the merged pair before reaching Terraform. crate::manifest::validate_node_volume(node_volume_type, node_volume_iops)?; @@ -1123,6 +1329,7 @@ async fn pre_start( cc_disk_gb, node_volume_type, node_volume_iops, + node_data_on_instance_store, )?; // Reload testnet with the recently created infra files @@ -1151,7 +1358,7 @@ async fn pre_start( // Build Docker images if they do not exist, for local infrastructure only if testnet.is_local() { - if let Err(err) = infra::docker::images_exist(&testnet.images) { + if let Err(err) = infra::docker::images_exist(&testnet.effective_images()) { warn!("Docker images do not exist: {err}; running `quake build`..."); testnet.build(args.build_args.profile).await?; } @@ -1205,4 +1412,32 @@ mod cli_tests { assert_eq!(err.kind(), ErrorKind::UnknownArgument); assert!(err.to_string().contains("validator1")); } + + #[test] + fn split_comma_names_parses_basic_list() { + let v = split_comma_names("validator1,validator2,ALL_VALIDATORS").unwrap(); + assert_eq!( + v, + vec![ + "validator1".to_string(), + "validator2".to_string(), + "ALL_VALIDATORS".to_string(), + ] + ); + } + + #[test] + fn split_comma_names_trims_whitespace_and_skips_empties() { + let v = split_comma_names(" validator1 , , ALL_VALIDATORS ").unwrap(); + assert_eq!( + v, + vec!["validator1".to_string(), "ALL_VALIDATORS".to_string(),] + ); + } + + #[test] + fn split_comma_names_rejects_empty_input() { + assert!(split_comma_names("").is_err()); + assert!(split_comma_names(",,,").is_err()); + } } diff --git a/crates/quake/src/manifest.rs b/crates/quake/src/manifest.rs index 4cab4cb2..b23a545d 100644 --- a/crates/quake/src/manifest.rs +++ b/crates/quake/src/manifest.rs @@ -23,7 +23,6 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use tracing::warn; -use arc_consensus_types::Config as ClConfigOverride; use arc_node_consensus_cli::cmd::start::StartCmd; use crate::infra; @@ -150,7 +149,6 @@ const EL_DEFAULT_RPC_API: &[&str] = &[ "admin", "net", "eth", "web3", "debug", "txpool", "trace", "reth", ]; const EL_DEFAULT_ENABLE_ARC_RPC: bool = true; -const EL_DEFAULT_ARC_DENYLIST_ENABLED: bool = true; const EL_DEFAULT_ARC_BUILDER_DEADLINE_MS: u64 = 100; fn default_rpc_api() -> Vec { @@ -158,12 +156,25 @@ fn default_rpc_api() -> Vec { } /// Execution layer (Reth) transaction pool configuration overrides. +/// +/// Reth caps each sub-pool on two independent dimensions — `count` and +/// `size in megabytes` — and treats either as binding. Quake exposes both: +/// the `*_max_count` fields cap transaction count and the `*_max_size` fields +/// cap the cumulative wire size in MB. Either limit alone leaves the pool +/// subject to the other's default (10 000 txs and 20 MB respectively in Reth +/// v1.11), so high-throughput saturation experiments typically need to lift +/// both. #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct ElTxpoolConfig { pub pending_max_count: Option, + pub pending_max_size: Option, pub basefee_max_count: Option, + pub basefee_max_size: Option, pub queued_max_count: Option, + pub queued_max_size: Option, + pub blobpool_max_count: Option, + pub blobpool_max_size: Option, pub max_account_slots: Option, pub lifetime: Option, pub max_batch_size: Option, @@ -174,8 +185,13 @@ impl Default for ElTxpoolConfig { fn default() -> Self { Self { pending_max_count: None, + pending_max_size: None, basefee_max_count: None, + basefee_max_size: None, queued_max_count: None, + queued_max_size: None, + blobpool_max_count: None, + blobpool_max_size: None, max_account_slots: None, lifetime: None, max_batch_size: None, @@ -218,6 +234,7 @@ pub struct ElEngineConfig { pub persistence_threshold: u64, pub memory_block_buffer_target: u64, pub legacy_state_root: Option, + pub share_sparse_trie_with_payload_builder: Option, } /// Execution layer (Reth) storage configuration overrides. @@ -324,23 +341,6 @@ impl Default for ElWsConfig { } } -/// Execution layer denylist configuration overrides. -/// -/// Fields correspond to `--arc.denylist.*` CLI flags. -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -#[serde(deny_unknown_fields, default)] -pub struct ElArcDenylistConfig { - pub enabled: bool, -} - -impl Default for ElArcDenylistConfig { - fn default() -> Self { - Self { - enabled: EL_DEFAULT_ARC_DENYLIST_ENABLED, - } - } -} - /// Execution layer Arc-specific payload builder overrides. /// /// Fields correspond to `--arc.builder.*` CLI flags. @@ -366,18 +366,29 @@ impl Default for ElArcBuilderConfig { } } +/// Execution layer transaction-relay configuration overrides. +/// +/// Maps to `--arc.tx.relays`: upstream RPC URLs raw-tx submission is +/// relayed to, in priority order, with failover. Empty disables relaying. +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct ElRelayConfig { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub relays: Vec, +} + /// Execution layer Arc-specific configuration overrides. /// /// Groups overrides for `--arc.*` CLI flags. #[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct ElArcConfig { - pub denylist: ElArcDenylistConfig, /// When true, passes `--arc.expose-pending-txs` to disable the pending-tx /// RPC filter. Default false (hidden); flip only on trusted/internal nodes. #[serde(default)] pub expose_pending_txs: bool, pub builder: ElArcBuilderConfig, + pub tx: ElRelayConfig, } /// Execution layer (Reth) pruning configuration for an individual data segment. @@ -415,7 +426,8 @@ impl std::fmt::Display for ElPruningPreset { } /// CL pruning preset — emitted as `--full` or `--minimal` on the CL binary. -/// Mutually exclusive with explicit `cl.config.prune.*` values. +/// Mutually exclusive with explicit `cl.config.prune_certificates_distance` / +/// `cl.config.prune_certificates_before` values. #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ClPruningPreset { @@ -543,6 +555,9 @@ pub(crate) struct Manifest { /// Provisioned IOPS for the node root EBS volume (remote only). /// Only meaningful for `gp3`, `io1`, and `io2` volume types. pub node_volume_iops: Option, + /// Place the node data directory on local instance-store NVMe instead of the root EBS + /// volume (remote only). Requires an instance type with local NVMe; a no-op otherwise. + pub node_data_on_instance_store: bool, /// CPU limit for the EL container (Docker `cpus`). Whole or fractional CPUs. pub el_cpu_limit: Option, /// Memory limit for the EL container, in GiB. Fractional values are allowed (e.g. 2.5). @@ -594,46 +609,25 @@ pub struct ClGossipSubConfig { pub load: Option, } -/// CL configuration for a node, version-dependent. -/// -/// - `Modern`: for CL >= v0.5.0, maps directly to CLI flags via [`StartCmd`]. -/// - `Legacy`: for CL < v0.5.0, serializes to `config.toml` via [`ClConfigOverride`]. -#[derive(Debug, Clone, PartialEq)] -pub enum NodeClConfig { - Modern(StartCmd), - Legacy(ClConfigOverride), -} - -impl Default for NodeClConfig { - fn default() -> Self { - Self::Modern(StartCmd::default()) - } -} - -impl NodeClConfig { - /// Whether the consensus engine runs for this node. - /// - /// Sync-only followers disable consensus via `--no-consensus` (Modern) or - /// `consensus.enabled = false` (Legacy). - pub fn consensus_enabled(&self) -> bool { - match self { - Self::Modern(cmd) => !cmd.no_consensus, - Self::Legacy(cfg) => cfg.consensus.enabled, - } - } -} - #[derive(Debug, Default, Clone, PartialEq)] pub struct Node { /// The type of the node pub node_type: NodeType, - /// Consensus layer configuration (version-dependent) - pub cl_config: NodeClConfig, + /// Consensus layer configuration. Maps directly to `arc-node-consensus start` + /// CLI flags via [`StartCmd`]. + pub cl_config: StartCmd, /// Execution layer (Reth) CLI flags for this node pub el_config: ElConfigOverride, + /// Effective consensus layer image for this node (inline override, else the + /// node-group override), or `None` to use the global image. Mixed-version networks. + pub image_cl: Option, + + /// Effective execution layer image for this node. See `image_cl`. + pub image_el: Option, + /// The height to start the node at pub start_at: Option, @@ -667,7 +661,8 @@ pub struct Node { pub cl_voting_power: Option, /// CL pruning preset — emitted as `--full` or `--minimal` on the CL binary. - /// Mutually exclusive with explicit `cl.config.prune.*` values. + /// Mutually exclusive with explicit `cl.config.prune_certificates_distance` / + /// `cl.config.prune_certificates_before` values. pub cl_prune_preset: Option, /// Address to receive transaction fees and block rewards (--suggested-fee-recipient). @@ -677,6 +672,14 @@ pub struct Node { /// External validators are expected to be multi-hop in mesh health checks /// rather than fully-connected. Also applies to their dedicated sentries. pub external: bool, + + /// Environment variables for the execution layer (Reth) container, merged + /// from the global `el.env` table and this node's `el.env` overrides. + pub el_env: IndexMap, + + /// Environment variables for the consensus layer (Malachite) container, merged + /// from the global `cl.env` table and this node's `cl.env` overrides. + pub cl_env: IndexMap, } impl Node { @@ -709,15 +712,11 @@ impl Node { if self.cl_prune_preset.is_some() { return true; } - match &self.cl_config { - NodeClConfig::Modern(cmd) => { - cmd.full - || cmd.minimal - || cmd.prune_certificates_distance > 0 - || cmd.prune_certificates_before > 0 - } - NodeClConfig::Legacy(cfg) => cfg.prune.enabled(), - } + let cmd = &self.cl_config; + cmd.full + || cmd.minimal + || cmd.prune_certificates_distance > 0 + || cmd.prune_certificates_before > 0 } /// Returns the execution layer (Reth) CLI flags for this node, defined in the @@ -743,6 +742,20 @@ impl Node { Ok(cli_flags) } + + /// Effective `(cl, el)` images for this node: its per-node override for each + /// layer, else the given global image. Single source of the override-else-global + /// rule, shared by local and remote resolution so the two cannot drift. + pub fn effective_images(&self, global_cl: &str, global_el: &str) -> (String, String) { + ( + self.image_cl + .clone() + .unwrap_or_else(|| global_cl.to_string()), + self.image_el + .clone() + .unwrap_or_else(|| global_el.to_string()), + ) + } } #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, Default)] @@ -761,7 +774,7 @@ impl Manifest { Self::from_string(&content) } - fn from_string(content: &str) -> Result { + pub(crate) fn from_string(content: &str) -> Result { let raw: RawManifest = toml::from_str(content).wrap_err("Failed to parse manifest")?; let manifest = Manifest::try_from(raw)?; manifest.validate()?; @@ -837,6 +850,21 @@ impl Manifest { Ok(resolved.into_iter().collect()) } + /// Resolve node selectors if selectors is Some, or default to all nodes if None. + pub(crate) fn resolve_optional_node_selectors( + &self, + selectors: Option<&[String]>, + ) -> Result> { + let node_names = match selectors { + None => self.nodes.keys().cloned().collect(), + Some(sel) => self.resolve_node_selectors(sel)?, + }; + if node_names.is_empty() { + bail!("Target selector resolved to zero nodes"); + } + Ok(node_names) + } + /// Collects explicit voting powers from validators, or `None` if none are set. pub(crate) fn validator_voting_powers(&self) -> Option> { let powers: Vec = self @@ -1030,6 +1058,17 @@ impl Manifest { // Check that all subnets are connected through bridge nodes. self.subnets.validate_topology()?; + // Validate per-node byzantine configuration. + #[cfg(feature = "byzantine")] + for (node_name, node) in self.nodes.iter() { + let byz = node.cl_config.byzantine.as_ref(); + if let Some(byz) = byz { + byz.validate().with_context(|| { + format!("Invalid byzantine configuration for node '{node_name}'") + })?; + } + } + Ok(()) } @@ -1152,6 +1191,14 @@ pub(crate) struct DockerImages { pub el_upgrade: Option, } +/// Per-node or per-node-group CL/EL image override for mixed-version networks. +/// Absent fields fall back to the global manifest images. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)] +pub(crate) struct ImageOverride { + pub image_cl: Option, + pub image_el: Option, +} + impl DockerImages { /// Resolve an explicit image or substitute env vars in a default pattern. fn resolve_image(explicit: &Option, default: &str) -> Result { @@ -1202,15 +1249,6 @@ mod tests { use malachitebft_config::LogLevel; use std::env; - /// Extract the inner `ClConfigOverride` from a `NodeClConfig::Legacy` variant. - /// Panics if the variant is `Modern`. - fn unwrap_legacy(cl_config: &NodeClConfig) -> &ClConfigOverride { - match cl_config { - NodeClConfig::Legacy(cfg) => cfg, - NodeClConfig::Modern(_) => panic!("expected NodeClConfig::Legacy, got Modern"), - } - } - // Check number of nodes, names, types, and order of declaration in the manifest fn validate_nodes( nodes: &IndexMap, @@ -1273,12 +1311,11 @@ mod tests { let str = r#" name = "testnet" description = "test" - image_cl = "arc_consensus:v0.4.0" - cl.config.logging.log_level = "warn" + cl.config.log_level = "warn" [nodes.validator1] - cl.config.logging.log_level = "info" + cl.config.log_level = "info" [nodes.validator2] - cl.config.consensus.p2p.rpc_max_size = "123kb" + cl.config.discovery_num_outbound_peers = 42 [nodes.validator3] "#; let manifest = Manifest::from_string(str).unwrap(); @@ -1295,22 +1332,21 @@ mod tests { ]; validate_nodes(&manifest.nodes, expected_node_names, expected_types); - // Check nodes individual config (Legacy variant because image_cl is v0.4.0) - let v1 = unwrap_legacy(&manifest.nodes["validator1"].cl_config); - assert_eq!(v1.logging.log_level, LogLevel::Info); + // Node-level config overrides the global default; unset fields fall back to global. + let v1 = &manifest.nodes["validator1"].cl_config; + assert_eq!(v1.log_level, Some(LogLevel::Info)); - let v2 = unwrap_legacy(&manifest.nodes["validator2"].cl_config); - assert_eq!(v2.logging.log_level, LogLevel::Warn); - assert_eq!(v2.consensus.p2p.rpc_max_size, bytesize::ByteSize::kb(123)); + let v2 = &manifest.nodes["validator2"].cl_config; + assert_eq!(v2.log_level, Some(LogLevel::Warn)); + assert_eq!(v2.discovery_num_outbound_peers, 42); - let v3 = unwrap_legacy(&manifest.nodes["validator3"].cl_config); - assert_eq!(v3.logging.log_level, LogLevel::Warn); + let v3 = &manifest.nodes["validator3"].cl_config; + assert_eq!(v3.log_level, Some(LogLevel::Warn)); } #[test] fn test_load_invalid_global_cl_config() { let str = r#" - image_cl = "arc_consensus:v0.4.0" cl.config.foo = 1 [nodes.validator1] "#; @@ -1321,7 +1357,6 @@ mod tests { #[test] fn test_load_invalid_node_config() { let str = r#" - image_cl = "arc_consensus:v0.4.0" [nodes.validator1] cl.config.foo = 1 "#; @@ -1329,6 +1364,146 @@ mod tests { assert!(result.is_err(), "Expected node config to be invalid"); } + #[test] + fn test_rejects_pre_v0_5_0_cl_image() { + // Pre-v0.5.0 CL releases need a config.toml that Quake no longer + // generates, so pinning one must fail at manifest load. + let str = r#" + image_cl = "arc_consensus:v0.4.0" + [nodes.validator1] + "#; + let err = Manifest::from_string(str).unwrap_err(); + assert!( + err.to_string().contains("predates v0.5.0"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_rejects_pre_v0_5_0_cl_upgrade_image() { + let str = r#" + image_cl = "arc_consensus:latest" + image_cl_upgrade = "arc_consensus:v0.4.0" + [nodes.validator1] + "#; + let err = Manifest::from_string(str).unwrap_err(); + assert!( + err.to_string().contains("predates v0.5.0"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_per_node_image_override() { + let str = r#" + image_cl = "arc_consensus:latest" + image_el = "arc_execution:latest" + [nodes.validator1] + image_cl = "arc_consensus:0.5.0" + image_el = "arc_execution:0.5.0" + [nodes.validator2] + "#; + let manifest = Manifest::from_string(str).unwrap(); + assert_eq!( + manifest.nodes["validator1"].image_cl, + Some("arc_consensus:0.5.0".to_string()) + ); + assert_eq!( + manifest.nodes["validator1"].image_el, + Some("arc_execution:0.5.0".to_string()) + ); + // No override stays None; the global image is applied later (nodes.rs). + assert!(manifest.nodes["validator2"].image_cl.is_none()); + assert!(manifest.nodes["validator2"].image_el.is_none()); + } + + #[test] + fn test_group_image_override_applies_to_members_only() { + let str = r#" + [node_groups] + OLDIES = ["validator1"] + [group_images.OLDIES] + image_cl = "arc_consensus:0.5.0" + image_el = "arc_execution:0.5.0" + [nodes.validator1] + [nodes.validator2] + "#; + let manifest = Manifest::from_string(str).unwrap(); + assert_eq!( + manifest.nodes["validator1"].image_cl, + Some("arc_consensus:0.5.0".to_string()) + ); + assert_eq!( + manifest.nodes["validator1"].image_el, + Some("arc_execution:0.5.0".to_string()) + ); + assert!(manifest.nodes["validator2"].image_cl.is_none()); + } + + #[test] + fn test_per_node_image_overrides_group() { + let str = r#" + [node_groups] + OLDIES = ["validator1"] + [group_images.OLDIES] + image_cl = "arc_consensus:0.5.0" + [nodes.validator1] + image_cl = "arc_consensus:0.6.0" + "#; + let manifest = Manifest::from_string(str).unwrap(); + // The inline node override wins over the group override. + assert_eq!( + manifest.nodes["validator1"].image_cl, + Some("arc_consensus:0.6.0".to_string()) + ); + } + + #[test] + fn test_rejects_ambiguous_group_images() { + let str = r#" + [node_groups] + A = ["validator1"] + B = ["validator1"] + [group_images.A] + image_cl = "arc_consensus:0.5.0" + [group_images.B] + image_cl = "arc_consensus:0.6.0" + [nodes.validator1] + "#; + let err = Manifest::from_string(str).unwrap_err(); + assert!( + err.to_string().contains("from groups"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_rejects_unknown_group_images_key() { + let str = r#" + [group_images.NOPE] + image_cl = "arc_consensus:0.5.0" + [nodes.validator1] + "#; + let err = Manifest::from_string(str).unwrap_err(); + assert!( + err.to_string().contains("unknown node group"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_rejects_pre_v0_5_0_per_node_cl_image() { + let str = r#" + [nodes.validator1] + image_cl = "arc_consensus:v0.4.0" + "#; + let err = Manifest::from_string(str).unwrap_err(); + assert!( + err.to_string().contains("predates v0.5.0"), + "unexpected error: {err}" + ); + } + #[test] fn test_load_manifest_without_latency_emulation() { let str = r#" @@ -1411,17 +1586,16 @@ mod tests { #[test] fn test_node_with_empty_config_uses_global() { let str = r#" - image_cl = "arc_consensus:v0.4.0" [cl.config] - consensus.enabled = false + no_consensus = true [nodes.validator-0] cl.config = {} # explicitly empty "#; let result = Manifest::from_string(str).unwrap(); - // Verify the node inherited global config (Legacy variant because image_cl is v0.4.0) - let cfg = unwrap_legacy(&result.nodes["validator-0"].cl_config); - assert!(!cfg.consensus.enabled); + // Verify the node inherited the global config. + let cfg = &result.nodes["validator-0"].cl_config; + assert!(cfg.no_consensus); } #[test] @@ -1512,6 +1686,30 @@ mod tests { .contains("invalid el_trusted_peers entry 'nonexistent'")); } + #[test] + fn test_el_cli_flags_forward_trusted_only_but_not_trusted_peers() { + let str = r#" + [nodes.validator1.el.config] + trusted_peers = ["validator2"] + trusted_only = true + [nodes.validator2] + "#; + let manifest = Manifest::from_string(str).unwrap(); + let node = &manifest.nodes["validator1"]; + + assert_eq!(node.el_trusted_peers, Some(vec!["validator2".to_string()])); + + let flags = node.el_cli_flags().unwrap(); + assert!( + flags.iter().any(|flag| flag == "--trusted-only"), + "trusted_only=true should be forwarded as --trusted-only: {flags:?}" + ); + assert!( + !flags.iter().any(|flag| flag.starts_with("--trusted-peers")), + "trusted_peers is generated by Quake setup and should not be forwarded directly: {flags:?}" + ); + } + #[test] fn test_validate_valid_cl_persistent_peers() { let str = r#" @@ -1717,6 +1915,103 @@ mod tests { .contains(&"--http".to_string())); } + #[test] + fn test_el_config_txpool_subpool_size_flags() { + // Setting `txpool.*_max_size` in the manifest must emit the corresponding + // `--txpool.*-max-size=N` Reth CLI flag for every node. Reth caps each + // sub-pool on both count and size in MB independently, so the size + // dimension must be tunable from the manifest just like count. + let str = r#" + [el.config.txpool] + pending_max_size = 200 + basefee_max_size = 100 + queued_max_size = 150 + blobpool_max_size = 50 + blobpool_max_count = 75 + + [nodes.validator1] + [nodes.validator2] + "#; + let manifest = Manifest::from_string(str).unwrap(); + for node_name in ["validator1", "validator2"] { + let flags = manifest.nodes[node_name].el_cli_flags().unwrap(); + assert!( + flags.contains(&"--txpool.pending-max-size=200".to_string()), + "{node_name} missing pending-max-size flag: {flags:?}" + ); + assert!( + flags.contains(&"--txpool.basefee-max-size=100".to_string()), + "{node_name} missing basefee-max-size flag: {flags:?}" + ); + assert!( + flags.contains(&"--txpool.queued-max-size=150".to_string()), + "{node_name} missing queued-max-size flag: {flags:?}" + ); + assert!( + flags.contains(&"--txpool.blobpool-max-size=50".to_string()), + "{node_name} missing blobpool-max-size flag: {flags:?}" + ); + assert!( + flags.contains(&"--txpool.blobpool-max-count=75".to_string()), + "{node_name} missing blobpool-max-count flag: {flags:?}" + ); + } + } + + #[test] + fn test_el_config_engine_share_sparse_trie_with_payload_builder() { + // `share_sparse_trie_with_payload_builder = true` must emit the bare + // `--engine.share-sparse-trie-with-payload-builder` flag, which reth + // parses as a presence flag. `false` or absent must emit nothing, + // since the flag defaults to false in reth. + let flag = "--engine.share-sparse-trie-with-payload-builder".to_string(); + + let enabled = r#" + [el.config.engine] + share_sparse_trie_with_payload_builder = true + + [nodes.validator1] + [nodes.validator2] + "#; + let manifest = Manifest::from_string(enabled).unwrap(); + for node in ["validator1", "validator2"] { + let flags = manifest.nodes[node].el_cli_flags().unwrap(); + assert!( + flags.contains(&flag), + "{node}: true must emit the bare flag, got: {flags:?}" + ); + } + + let disabled = r#" + [el.config.engine] + share_sparse_trie_with_payload_builder = false + + [nodes.validator1] + [nodes.validator2] + "#; + let manifest = Manifest::from_string(disabled).unwrap(); + for node in ["validator1", "validator2"] { + let flags = manifest.nodes[node].el_cli_flags().unwrap(); + assert!( + !flags.iter().any(|f| f.contains("share-sparse-trie")), + "{node}: false must emit no flag, got: {flags:?}" + ); + } + + let unset = r#" + [nodes.validator1] + [nodes.validator2] + "#; + let manifest = Manifest::from_string(unset).unwrap(); + for node in ["validator1", "validator2"] { + let flags = manifest.nodes[node].el_cli_flags().unwrap(); + assert!( + !flags.iter().any(|f| f.contains("share-sparse-trie")), + "{node}: absent must emit no flag, got: {flags:?}" + ); + } + } + #[test] fn test_el_config_per_node_overrides_all() { // Per-node EL config should override both defaults and manifest global @@ -1960,7 +2255,7 @@ mod tests { #[test] fn test_el_pruning_preset_roundtrip() { let toml_str = r#" - image_cl = "arc_consensus:v0.4.0" + image_cl = "arc_consensus:latest" el.config.prune.preset = "minimal" el.config.prune.bodies.distance = 100 @@ -2071,6 +2366,33 @@ mod tests { .any(|f| f.contains("rpc.forwarder"))); } + #[test] + fn test_arc_tx_relays_set_in_priority_order() { + let str = r#" + [nodes.full1] + el.config.arc.tx.relays = ["http://validator1_el:8545", "http://validator2_el:8545"] + "#; + let manifest = Manifest::from_string(str).unwrap(); + + assert!(manifest.nodes["full1"].el_cli_flags().unwrap().contains( + &"--arc.tx.relays=http://validator1_el:8545,http://validator2_el:8545".to_string() + )); + } + + #[test] + fn test_arc_tx_relays_omitted_when_unset() { + let str = r#" + [nodes.full1] + "#; + let manifest = Manifest::from_string(str).unwrap(); + + assert!(!manifest.nodes["full1"] + .el_cli_flags() + .unwrap() + .iter() + .any(|f| f.contains("arc.tx.relays"))); + } + #[test] fn test_arc_builder_wait_for_payload() { let str = r#" @@ -2583,7 +2905,7 @@ mod tests { #[test] fn test_voting_power_roundtrip() { let str = r#" - image_cl = "arc_consensus:v0.4.0" + image_cl = "arc_consensus:latest" [nodes.validator1] cl_voting_power = 2000 [nodes.validator2] diff --git a/crates/quake/src/manifest/flags.rs b/crates/quake/src/manifest/flags.rs index 49ac3465..cc46c036 100644 --- a/crates/quake/src/manifest/flags.rs +++ b/crates/quake/src/manifest/flags.rs @@ -51,6 +51,10 @@ static RESERVED_EL_FLAGS: LazyLock> = LazyLock::new(|| { ]) }); +/// reth booleans that default to true; their false value must be emitted +/// explicitly (`--key=false`) instead of omitted. +const EXPLICIT_BOOL_FLAGS: &[&str] = &["arc.builder.wait-for-payload", "storage.v2"]; + /// Flag to enable the HTTP-RPC server in the EL. /// Note: this is our custom key. Reth uses `--http` to enable HTTP, /// but in quake's manifests we use `http.enable = true` to comply with TOML syntax. @@ -157,21 +161,13 @@ fn flatten_toml_to_flags(table: &toml::Table, prefix: String, flags: &mut Vec { - // arc.builder.wait-for-payload is the only boolean that defaults to - // true, so it must be explicitly set to false in the manifest for - // it to be disabled. - // For all other booleans, we follow the common convention of - // emitting a flag only when the value is true, and omitting it when - // false. - // NOTE: if we have more booleans with non-false defaults in the - // future, we may want to refactor this logic to use a list of - // "explicit" booleans that require `--key=true/false`, instead of - // hardcoding the every special case. - if full_key.eq("arc.builder.wait-for-payload") { + // EXPLICIT_BOOL_FLAGS default to true in reth, so false must be + // emitted as `--key=false` rather than omitted. + if EXPLICIT_BOOL_FLAGS.contains(&full_key.as_str()) { flags.push(format!("--{}={}", full_key, b)); - return; + continue; } - // Other booleans: true = flag present, false = omit + // Other booleans: true = flag present, false = omit. if *b { let flag_key = match full_key.as_str() { // Strip ".enable" suffix for http and ws flags only. @@ -195,7 +191,7 @@ fn flatten_toml_to_flags(table: &toml::Table, prefix: String, flags: &mut Vec "vvv", // default to info }; flags.push(format!("-{}", verbosity)); - return; + continue; } flags.push(format!("--{}={}", full_key, s)); } @@ -274,6 +270,19 @@ mod tests { assert!(!flags.iter().any(|f| f.contains("disable-discovery"))); } + #[test] + fn test_explicit_bool_flag_emits_false() { + use super::el_config_to_cli_flags; + + // storage.v2 defaults to true in reth, so false must be emitted (not omitted) + // to initialize a node in v1 layout. + let flags = el_config_to_cli_flags(&toml::toml! { storage.v2 = false }); + assert!(flags.contains(&"--storage.v2=false".to_string())); + + let flags = el_config_to_cli_flags(&toml::toml! { storage.v2 = true }); + assert!(flags.contains(&"--storage.v2=true".to_string())); + } + #[test] fn test_toml_table_d_alias() { use super::el_config_to_cli_flags; @@ -438,6 +447,28 @@ mod tests { } } + #[test] + fn test_log_level_does_not_drop_sibling_flags() { + use super::el_config_to_cli_flags; + + // `log.level` must not abort iteration over its sibling keys. + let mut log = toml::Table::new(); + log.insert("level".to_string(), toml::Value::String("info".to_string())); + log.insert("stdout".to_string(), toml::Value::Boolean(true)); + let mut table = toml::Table::new(); + table.insert("log".to_string(), toml::Value::Table(log)); + + let flags = el_config_to_cli_flags(&table); + assert!( + flags.contains(&"-vvv".to_string()), + "log.level should still emit verbosity, got: {flags:?}" + ); + assert!( + flags.contains(&"--log.stdout".to_string()), + "sibling flag after log.level must not be dropped, got: {flags:?}" + ); + } + #[test] fn test_arc_builder_bool_emits_explicit_value() { use super::el_config_to_cli_flags; diff --git a/crates/quake/src/manifest/generate.rs b/crates/quake/src/manifest/generate.rs index 27b39b31..06fe0e3d 100644 --- a/crates/quake/src/manifest/generate.rs +++ b/crates/quake/src/manifest/generate.rs @@ -274,6 +274,7 @@ impl Manifest { block_gas_limit: None, node_volume_type: None, node_volume_iops: None, + node_data_on_instance_store: false, el_cpu_limit: None, el_memory_limit_gb: None, cl_cpu_limit: None, @@ -298,7 +299,7 @@ impl Manifest { } /// Build random per-node CL (Consensus Layer) config. - fn random_cl_node_config(rng: &mut StdRng, allow_no_consensus: bool) -> manifest::NodeClConfig { + fn random_cl_node_config(rng: &mut StdRng, allow_no_consensus: bool) -> StartCmd { use malachitebft_config::{LogFormat, LogLevel}; // Runtime: 30% single_threaded, 70% multi_threaded; worker_threads 1-16 when multi. @@ -339,7 +340,7 @@ impl Manifest { let no_consensus = allow_no_consensus && rng.gen_bool(0.1); - manifest::NodeClConfig::Modern(StartCmd { + StartCmd { runtime_flavor, worker_threads, prune_certificates_distance: distance, @@ -348,7 +349,7 @@ impl Manifest { log_format, no_consensus, ..StartCmd::default() - }) + } } /// Build random per-node EL (Execution Layer) config override. @@ -885,9 +886,7 @@ mod tests { let manifest = Manifest::generate_random(100, &config).unwrap(); for (node_id, node) in &manifest.nodes { - let manifest::NodeClConfig::Modern(cmd) = &node.cl_config else { - panic!("node {node_id}: expected Modern cl_config"); - }; + let cmd = &node.cl_config; assert!( cmd != &StartCmd::default(), @@ -1044,9 +1043,7 @@ mod tests { .nodes .get(name) .unwrap_or_else(|| panic!("seed {seed}: {name} missing")); - let manifest::NodeClConfig::Modern(cmd) = &node.cl_config else { - panic!("seed {seed}: {name} expected Modern cl_config"); - }; + let cmd = &node.cl_config; assert!( !cmd.no_consensus, "seed {seed}: {name} must keep no_consensus=false (would partition consensus)" @@ -1056,9 +1053,7 @@ mod tests { if node.node_type != NodeType::Validator { continue; } - let manifest::NodeClConfig::Modern(cmd) = &node.cl_config else { - panic!("seed {seed}: validator {name} expected Modern cl_config"); - }; + let cmd = &node.cl_config; assert!( !cmd.no_consensus, "seed {seed}: validator {name} must never have no_consensus=true" diff --git a/crates/quake/src/manifest/raw.rs b/crates/quake/src/manifest/raw.rs index 1c720fd3..b87cedca 100644 --- a/crates/quake/src/manifest/raw.rs +++ b/crates/quake/src/manifest/raw.rs @@ -15,17 +15,16 @@ // limitations under the License. use alloy_primitives::Address; -use arc_consensus_types::Config as ClConfigOverride; use color_eyre::eyre::{bail, Result}; use indexmap::{IndexMap, IndexSet}; use serde::{Deserialize, Serialize}; use tracing::warn; -use crate::cli_version::supports_cli_flags; +use crate::cli_version::ensure_cl_image_supported; use crate::manifest::subnets::Subnets; use crate::manifest::{ default_subnet_singleton, ClGossipSubConfig, ClPruningPreset, DockerImages, ElConfigOverride, - EngineApiConnection, Manifest, Node, NodeClConfig, NodeType, RemoteKeyId, + EngineApiConnection, ImageOverride, Manifest, Node, NodeType, RemoteKeyId, }; use crate::node::SubnetName; use crate::util::merge_toml_values; @@ -70,33 +69,122 @@ pub struct ElConfig { /// Execution layer (Reth) CLI flags as a TOML table. /// Keys become flag names, values become flag values. /// e.g., `builder.deadline = 5` becomes `--builder.deadline=5` + #[serde(skip_serializing_if = "is_empty_table")] pub config: toml::Table, + + /// Environment variables for the execution layer container. + /// Keys become env var names, scalar values become their string form. + /// e.g., `el.env.RUST_LOG = "debug"`. + #[serde(skip_serializing_if = "is_empty_table")] + pub env: toml::Table, } /// Wrapper for consensus layer configuration in TOML. /// -/// Supports the `cl.config` TOML syntax where `config` is a table -/// of Malachite configuration fields. +/// Supports the `cl.config` TOML syntax where `config` is a table of +/// consensus CLI-flag fields. /// /// # Example /// ```toml /// [cl.config] -/// logging.log_level = "debug" +/// log_level = "debug" /// ``` /// or equivalently: /// ```toml -/// cl.config.logging.log_level = "debug" +/// cl.config.log_level = "debug" /// ``` #[derive(Debug, Deserialize, Default, Serialize, PartialEq)] #[serde(default)] pub struct ClConfig { + #[serde(skip_serializing_if = "is_empty_table")] pub config: toml::Table, + + /// Environment variables for the consensus layer container. + /// Keys become env var names, scalar values become their string form. + /// e.g., `cl.env.ARC_HALT_AT_BLOCK_HEIGHT = 100`. + #[serde(skip_serializing_if = "is_empty_table")] + pub env: toml::Table, } fn is_default(v: &T) -> bool { *v == T::default() } +fn is_empty_table(table: &toml::Table) -> bool { + table.is_empty() +} + +/// Merge a global env table with a node-specific one, the node values winning on +/// matching keys. The env tables are flat (one level of scalar values), so a flat +/// override is sufficient — no recursive merge like CLI config tables. +fn merge_env_tables(global: &toml::Table, node: &toml::Table) -> toml::Table { + let mut merged = global.clone(); + for (key, value) in node { + merged.insert(key.clone(), value.clone()); + } + merged +} + +/// Whether `key` is a valid environment variable name: a leading letter or +/// underscore followed by letters, digits, or underscores (`^[A-Za-z_][A-Za-z0-9_]*$`). +/// TOML permits quoted keys with arbitrary characters, but those would render as +/// invalid or injected YAML in the compose `environment:` block. +fn is_valid_env_key(key: &str) -> bool { + let mut chars = key.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Convert an env table into a string map, coercing scalar values to their string +/// representation. Arrays and tables are rejected: an environment variable value is +/// always a single string. Keys are validated as environment variable names. +fn env_table_to_map( + table: &toml::Table, + node_name: &str, + layer: &str, +) -> Result> { + let mut map = IndexMap::with_capacity(table.len()); + for (key, value) in table { + if !is_valid_env_key(key) { + bail!( + "{node_name}: {layer}.env key {key:?} is not a valid environment variable name \ + (expected ^[A-Za-z_][A-Za-z0-9_]*$)" + ); + } + let rendered = match value { + toml::Value::String(s) => s.clone(), + toml::Value::Integer(i) => i.to_string(), + toml::Value::Float(f) => f.to_string(), + toml::Value::Boolean(b) => b.to_string(), + other => bail!( + "{node_name}: {layer}.env.{key} must be a string, integer, float, or boolean, \ + got {}", + other.type_str() + ), + }; + if let Some(c) = rendered.chars().find(|c| c.is_control()) { + bail!( + "{node_name}: {layer}.env.{key} contains a control character (U+{:04X}); \ + environment variable values must be single-line, control-char-free", + c as u32 + ); + } + map.insert(key.clone(), rendered); + } + Ok(map) +} + +/// Convert a string env map back into a TOML table of string values, for the +/// `Manifest` → `RawManifest` round-trip. +fn env_map_to_table(env: &IndexMap) -> toml::Table { + env.iter() + .map(|(k, v)| (k.clone(), toml::Value::String(v.clone()))) + .collect() +} + fn is_default_subnet(v: &Vec) -> bool { *v == default_subnet_singleton() } @@ -120,6 +208,15 @@ pub struct RawNode { #[serde(skip_serializing_if = "is_default")] el: ElConfig, + /// Per-node consensus layer image override (mixed-version networks). + /// Takes precedence over any node-group override and the global image. + #[serde(default, skip_serializing_if = "Option::is_none")] + image_cl: Option, + + /// Per-node execution layer image override. See `image_cl`. + #[serde(default, skip_serializing_if = "Option::is_none")] + image_el: Option, + start_at: Option, region: Option, @@ -197,6 +294,10 @@ pub struct RawManifest { nodes: IndexMap, #[serde(skip_serializing_if = "is_default")] node_groups: IndexMap>, + /// Per-node-group image overrides, keyed by an existing node-group name. + /// Applied to every member unless the node sets its own `image_cl`/`image_el`. + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + group_images: IndexMap, el_init_hardfork: Option, #[serde(default, alias = "image_tag_cl")] image_cl: Option, @@ -224,6 +325,10 @@ pub struct RawManifest { node_volume_type: Option, /// Provisioned IOPS for the node root EBS volume (remote only). node_volume_iops: Option, + /// Place the node data directory on local instance-store NVMe instead of the root EBS + /// volume (remote only). Requires an instance type with local NVMe; a no-op otherwise. + #[serde(skip_serializing_if = "is_default")] + node_data_on_instance_store: bool, /// CPU limit for the EL container (Docker `cpus`). Whole or fractional CPUs. el_cpu_limit: Option, /// Memory limit for the EL container, in GiB. Fractional values are allowed. @@ -260,6 +365,7 @@ impl Default for RawManifest { arc_image_registry: None, nodes: IndexMap::new(), node_groups: IndexMap::new(), + group_images: IndexMap::new(), el_init_hardfork: None, image_cl: None, image_el: None, @@ -273,6 +379,7 @@ impl Default for RawManifest { block_gas_limit: None, node_volume_type: None, node_volume_iops: None, + node_data_on_instance_store: false, el_cpu_limit: None, el_memory_limit_gb: None, cl_cpu_limit: None, @@ -281,22 +388,8 @@ impl Default for RawManifest { } } -/// Collect all leaf keys from a TOML table as dot-separated paths. -fn collect_toml_keys(table: &toml::Table, prefix: &str, out: &mut Vec) { - for (key, value) in table { - let path = if prefix.is_empty() { - key.clone() - } else { - format!("{prefix}.{key}") - }; - match value { - toml::Value::Table(sub) => collect_toml_keys(sub, &path, out), - _ => out.push(path), - } - } -} - -/// Reject manifests where a node sets both `cl_prune_preset` and `cl.config.prune.*`. +/// Reject manifests where a node sets both `cl_prune_preset` and an explicit +/// `cl.config.prune_certificates_distance` / `cl.config.prune_certificates_before`. /// These are mutually exclusive: the preset is a named shortcut while explicit prune /// config overrides individual knobs. Allowing both would make precedence ambiguous. fn validate_prune_exclusivity(raw: &RawManifest) -> Result<()> { @@ -305,18 +398,56 @@ fn validate_prune_exclusivity(raw: &RawManifest) -> Result<()> { let node_has_prune = has_prune_keys(&raw_node.cl.config) || global_has_prune; if raw_node.cl_prune_preset.is_some() && node_has_prune { bail!( - "{node_name}: cl_prune_preset and cl.config.prune.* are mutually exclusive. \ - Use either a preset (full/minimal) or explicit prune settings, not both." + "{node_name}: cl_prune_preset and explicit \ + cl.config.prune_certificates_distance/prune_certificates_before are \ + mutually exclusive. Use either a preset (full/minimal) or explicit \ + prune settings, not both." ); } } Ok(()) } +/// Whether the table sets either explicit certificate-pruning knob. fn has_prune_keys(table: &toml::Table) -> bool { - let mut keys = Vec::new(); - collect_toml_keys(table, "", &mut keys); - keys.iter().any(|k| k.starts_with("prune.")) + table.contains_key("prune_certificates_distance") + || table.contains_key("prune_certificates_before") +} + +/// Resolve the image override that node-groups contribute to `node_name`. +/// Errors if more than one image-declaring group covers the node for the same +/// layer, since the winner would otherwise be arbitrary. +fn group_image_override( + node_name: &str, + group_images: &IndexMap, + node_groups: &IndexMap>, +) -> Result { + let mut cl: Option<(&str, &str)> = None; + let mut el: Option<(&str, &str)> = None; + for (gname, ovr) in group_images { + let Some(members) = node_groups.get(gname) else { + continue; + }; + if !members.iter().any(|m| m == node_name) { + continue; + } + if let Some(img) = ovr.image_cl.as_deref() { + if let Some((prev, _)) = cl { + bail!("Node '{node_name}' gets image_cl from groups '{prev}' and '{gname}'; remove the overlap"); + } + cl = Some((gname, img)); + } + if let Some(img) = ovr.image_el.as_deref() { + if let Some((prev, _)) = el { + bail!("Node '{node_name}' gets image_el from groups '{prev}' and '{gname}'; remove the overlap"); + } + el = Some((gname, img)); + } + } + Ok(ImageOverride { + image_cl: cl.map(|(_, i)| i.to_string()), + image_el: el.map(|(_, i)| i.to_string()), + }) } impl TryFrom for Manifest { @@ -329,9 +460,14 @@ impl TryFrom for Manifest { validate_prune_exclusivity(&raw)?; - // Build Docker images (needed early to determine CL config format) let images = raw.images(); + // Pre-v0.5.0 CL releases require a config.toml that Quake no longer + // generates. Reject them here rather than letting the container fail at + // startup with unrecognized CLI flags. + ensure_cl_image_supported(images.cl.as_deref())?; + ensure_cl_image_supported(images.cl_upgrade.as_deref())?; + let node_names = raw.nodes.keys().cloned().collect::>(); let custom_node_groups = raw.node_groups.clone(); for group_name in custom_node_groups.keys() { @@ -357,16 +493,19 @@ impl TryFrom for Manifest { } } + // Validate per-group image overrides: keys must name a known group, and + // group CL images are subject to the same version floor as global images. + for (group_name, ovr) in &raw.group_images { + if !node_groups.contains_key(group_name) { + bail!("group_images references unknown node group '{group_name}'"); + } + ensure_cl_image_supported(ovr.image_cl.as_deref())?; + } + // Merge default CL and EL configs with manifest's global config. // Precedence: defaults < manifest global < per-node - // The CL default depends on the image version: Modern uses StartCmd, - // Legacy uses ClConfigOverride. - let is_modern = supports_cli_flags(images.cl.as_deref()); - let default_cl = if is_modern { - toml::Value::try_from(arc_node_consensus_cli::cmd::start::StartCmd::default())? - } else { - toml::Value::try_from(ClConfigOverride::default())? - }; + let default_cl = + toml::Value::try_from(arc_node_consensus_cli::cmd::start::StartCmd::default())?; let manifest_cl = toml::Value::Table(raw.cl.config.clone()); let global_cl_config = merge_toml_values(default_cl, manifest_cl)?; @@ -374,6 +513,10 @@ impl TryFrom for Manifest { let manifest_el = toml::Value::Table(raw.el.config.clone()); let global_el_config = merge_toml_values(default_el, manifest_el)?; + // Global env tables, inherited by every node and overridden per-node. + let global_el_env = raw.el.env.clone(); + let global_cl_env = raw.cl.env.clone(); + // Build nodes map from raw nodes let mut nodes = IndexMap::new(); let mut node_subnets = IndexMap::new(); @@ -397,13 +540,19 @@ impl TryFrom for Manifest { // Merge node-specific CL config with global CL config let node_cl_config = toml::Value::Table(raw_node.cl.config); let cl_config_toml = merge_toml_values(global_cl_config.clone(), node_cl_config)?; - - // Version-branched deserialization - let cl_config = if is_modern { - NodeClConfig::Modern(cl_config_toml.try_into()?) - } else { - NodeClConfig::Legacy(cl_config_toml.try_into()?) - }; + let cl_config = cl_config_toml.try_into()?; + + // Merge global env with node-specific env (node wins) for each layer. + let el_env = env_table_to_map( + &merge_env_tables(&global_el_env, &raw_node.el.env), + &key, + "el", + )?; + let cl_env = env_table_to_map( + &merge_env_tables(&global_cl_env, &raw_node.cl.env), + &key, + "cl", + )?; // Merge global el.config with node-specific el.config as TOML let node_el_config = toml::Value::Table(raw_node.el.config); @@ -411,6 +560,13 @@ impl TryFrom for Manifest { let mut el_config: ElConfigOverride = el_config.try_into()?; + // Effective per-node images: inline override wins over the node-group + // override; both fall back to the global image later (nodes.rs). + let group_ovr = group_image_override(&key, &raw.group_images, &node_groups)?; + let image_cl = raw_node.image_cl.or(group_ovr.image_cl); + let image_el = raw_node.image_el.or(group_ovr.image_el); + ensure_cl_image_supported(image_cl.as_deref())?; + // Extract trusted_peers from el.config: expand group/node names, remove self, // and strip the key so it is not forwarded as a Reth CLI flag. let el_trusted_peers = if !el_config.trusted_peers.is_empty() { @@ -437,6 +593,8 @@ impl TryFrom for Manifest { node_type, cl_config, el_config, + image_cl, + image_el, start_at: raw_node.start_at, region: raw_node.region, cl_persistent_peers, @@ -450,6 +608,8 @@ impl TryFrom for Manifest { cl_prune_preset: raw_node.cl_prune_preset, cl_suggested_fee_recipient: raw_node.cl_suggested_fee_recipient, external: raw_node.external, + el_env, + cl_env, }, ); } @@ -478,6 +638,7 @@ impl TryFrom for Manifest { block_gas_limit: raw.block_gas_limit, node_volume_type: raw.node_volume_type, node_volume_iops: raw.node_volume_iops, + node_data_on_instance_store: raw.node_data_on_instance_store, el_cpu_limit: raw.el_cpu_limit, el_memory_limit_gb: raw.el_memory_limit_gb, cl_cpu_limit: raw.cl_cpu_limit, @@ -515,6 +676,9 @@ impl TryFrom for RawManifest { }) .collect::>()?, node_groups, + // Group overrides are flattened onto each node during the forward + // conversion, so they round-trip as per-node image_cl/image_el. + group_images: IndexMap::new(), el_init_hardfork: manifest.el_init_hardfork, image_cl: manifest.images.cl, image_el: manifest.images.el, @@ -530,6 +694,7 @@ impl TryFrom for RawManifest { block_gas_limit: manifest.block_gas_limit, node_volume_type: manifest.node_volume_type, node_volume_iops: manifest.node_volume_iops, + node_data_on_instance_store: manifest.node_data_on_instance_store, el_cpu_limit: manifest.el_cpu_limit, el_memory_limit_gb: manifest.el_memory_limit_gb, cl_cpu_limit: manifest.cl_cpu_limit, @@ -554,28 +719,26 @@ impl RawNode { let node_el_table = toml::Table::try_from(el_config)?; let default_el_config: toml::Table = toml::Table::try_from(ElConfigOverride::default())?; - // Serialize cl_config to TOML based on variant - let cl_config_table = match &node.cl_config { - NodeClConfig::Modern(start_cmd) => { - let table = toml::Table::try_from(start_cmd)?; - let default_table = - toml::Table::try_from(arc_node_consensus_cli::cmd::start::StartCmd::default())?; - Self::config_diff(&table, &default_table) - } - NodeClConfig::Legacy(config) => { - let table = toml::Table::try_from(config)?; - let default_table = toml::Table::try_from(ClConfigOverride::default())?; - Self::config_diff(&table, &default_table) - } + // Serialize cl_config to TOML, keeping only fields that differ from the + // StartCmd default. + let cl_config_table = { + let table = toml::Table::try_from(&node.cl_config)?; + let default_table = + toml::Table::try_from(arc_node_consensus_cli::cmd::start::StartCmd::default())?; + Self::config_diff(&table, &default_table) }; Ok(Self { cl: ClConfig { config: cl_config_table, + env: env_map_to_table(&node.cl_env), }, el: ElConfig { config: Self::config_diff(&node_el_table, &default_el_config), + env: env_map_to_table(&node.el_env), }, + image_cl: node.image_cl, + image_el: node.image_el, start_at: node.start_at, region: node.region, cl_persistent_peers: node.cl_persistent_peers, @@ -689,7 +852,7 @@ mod tests { #[test] fn test_el_trusted_peers_roundtrip() { let toml = r#" - image_cl = "arc_consensus:v0.4.0" + image_cl = "arc_consensus:latest" [nodes.val1.el.config] trusted_peers = ["val2"] [nodes.val2] @@ -728,7 +891,7 @@ mod tests { #[test] fn test_el_trusted_peers_global_roundtrip() { let toml = r#" - image_cl = "arc_consensus:v0.4.0" + image_cl = "arc_consensus:latest" [el.config] trusted_peers = ["val2"] [nodes.val1] @@ -765,7 +928,7 @@ mod tests { #[test] fn test_custom_node_groups_roundtrip() { let toml = r#" - image_cl = "arc_consensus:v0.4.0" + image_cl = "arc_consensus:latest" [node_groups] FULL_NODES = ["full1", "full2"] TRUSTED = ["ALL_VALIDATORS", "FULL_NODES", "other_node"] @@ -833,7 +996,7 @@ mod tests { image_cl = "ghcr.io/org/arc-consensus:latest" [nodes.val1] cl_prune_preset = "minimal" - cl.config.prune.certificates_distance = 500 + cl.config.prune_certificates_distance = 500 "#; let raw: RawManifest = toml::from_str(toml_str).unwrap(); let result = Manifest::try_from(raw); @@ -849,7 +1012,7 @@ mod tests { fn test_prune_preset_and_global_cl_config_prune_are_mutually_exclusive() { let toml_str = r#" image_cl = "ghcr.io/org/arc-consensus:latest" - cl.config.prune.certificates_distance = 500 + cl.config.prune_certificates_distance = 500 [nodes.val1] cl_prune_preset = "minimal" "#; @@ -880,42 +1043,15 @@ mod tests { ); } - #[test] - fn test_collect_toml_keys() { - let table: toml::Table = toml::from_str( - r#"[logging] -log_level = "info" -[consensus.p2p] -rpc_max_size = "42 Mib" -"#, - ) - .unwrap(); - - let mut keys = Vec::new(); - collect_toml_keys(&table, "", &mut keys); - keys.sort(); - assert_eq!( - keys, - vec!["consensus.p2p.rpc_max_size", "logging.log_level",] - ); - } - - #[test] - fn test_collect_toml_keys_empty_table() { - let mut keys = Vec::new(); - collect_toml_keys(&toml::Table::new(), "", &mut keys); - assert!(keys.is_empty()); - } - /// Manifest serialization should not include empty/default fields. /// Make sure that the default fields are skipped during serialization. #[test] fn test_default_manifest_serialization() { let node = Node { - cl_config: NodeClConfig::Modern(StartCmd { + cl_config: StartCmd { log_level: Some(LogLevel::Info), ..StartCmd::default() - }), + }, el_config: ElConfigOverride { txpool: crate::manifest::ElTxpoolConfig { pending_max_count: Some(2), @@ -946,4 +1082,166 @@ rpc_max_size = "42 Mib" "[nodes.val0.cl.config]\nlog_level = \"info\"\n\n[nodes.val0.el.config.txpool]\npending_max_count = 2\n\n[nodes.val1]\n" ); } + + /// node_data_on_instance_store round-trips through TOML → Manifest → RawManifest → TOML. + #[test] + fn test_node_data_on_instance_store_roundtrip() { + let toml = r#" + node_data_on_instance_store = true + [nodes.val1] + "#; + + let manifest1 = Manifest::from_string(toml).unwrap(); + assert!(manifest1.node_data_on_instance_store); + + let raw = RawManifest::try_from(manifest1).unwrap(); + let serialized = toml::to_string(&raw).unwrap(); + assert!(serialized.contains("node_data_on_instance_store = true")); + + let manifest2 = Manifest::from_string(&serialized).unwrap(); + assert!(manifest2.node_data_on_instance_store); + } + + /// Omitting node_data_on_instance_store defaults to false (datadir stays on root EBS). + #[test] + fn test_node_data_on_instance_store_defaults_to_false() { + let manifest = Manifest::from_string("[nodes.val1]\n").unwrap(); + assert!(!manifest.node_data_on_instance_store); + } + + /// Global `el.env`/`cl.env` are inherited by every node; per-node entries + /// override matching keys and add new ones. + #[test] + fn test_env_global_and_per_node_merge() { + let toml = r#" + [el.env] + RUST_LOG = "info" + SHARED = "global" + [cl.env] + ARC_HALT_AT_BLOCK_HEIGHT = 0 + + [nodes.val1.el.env] + RUST_LOG = "debug" + [nodes.val1.cl.env] + EXTRA = "x" + [nodes.val2] + "#; + + let manifest = Manifest::from_string(toml).unwrap(); + + // val1 overrides RUST_LOG, keeps inherited SHARED, adds cl EXTRA. + assert_eq!(manifest.nodes["val1"].el_env["RUST_LOG"], "debug"); + assert_eq!(manifest.nodes["val1"].el_env["SHARED"], "global"); + assert_eq!( + manifest.nodes["val1"].cl_env["ARC_HALT_AT_BLOCK_HEIGHT"], + "0" + ); + assert_eq!(manifest.nodes["val1"].cl_env["EXTRA"], "x"); + + // val2 inherits the global env unchanged. + assert_eq!(manifest.nodes["val2"].el_env["RUST_LOG"], "info"); + assert_eq!(manifest.nodes["val2"].el_env["SHARED"], "global"); + assert_eq!( + manifest.nodes["val2"].cl_env["ARC_HALT_AT_BLOCK_HEIGHT"], + "0" + ); + assert!(!manifest.nodes["val2"].cl_env.contains_key("EXTRA")); + } + + /// Non-string scalar env values are coerced to their string form. + #[test] + fn test_env_scalar_coercion() { + let toml = r#" + [nodes.val1.el.env] + COUNT = 42 + RATIO = 1.5 + FLAG = true + NAME = "hello" + "#; + + let manifest = Manifest::from_string(toml).unwrap(); + let env = &manifest.nodes["val1"].el_env; + assert_eq!(env["COUNT"], "42"); + assert_eq!(env["RATIO"], "1.5"); + assert_eq!(env["FLAG"], "true"); + assert_eq!(env["NAME"], "hello"); + } + + /// Array/table env values are rejected: an env var value must be a scalar. + #[test] + fn test_env_non_scalar_value_errors() { + let toml = r#" + [nodes.val1.el.env] + BAD = ["a", "b"] + "#; + + let err = Manifest::from_string(toml).unwrap_err().to_string(); + assert!( + err.contains("must be a string, integer, float, or boolean"), + "unexpected error: {err}" + ); + } + + /// Env keys that are not valid environment variable names are rejected, so they + /// cannot inject YAML-significant characters into the compose `environment:` block. + #[test] + fn test_env_invalid_key_rejected() { + for bad_key in ["BAD: KEY", "1LEADING_DIGIT", "has-hyphen"] { + let toml = format!("[nodes.val1.el.env]\n{bad_key:?} = \"x\"\n"); + let err = Manifest::from_string(&toml).unwrap_err().to_string(); + assert!( + err.contains("not a valid environment variable name"), + "key {bad_key:?} should be rejected, got: {err}" + ); + } + } + + /// Env values containing control characters (newline, tab, CR) are + /// rejected at parse time. Multi-line env vars don't survive shell + /// round-tripping and would break the compose YAML. (Null bytes are + /// rejected earlier by the TOML parser itself — `\0` isn't a TOML escape.) + #[test] + fn test_env_control_char_value_rejected() { + for (label, bad_value) in [ + ("newline", "first\nsecond"), + ("carriage return", "first\rsecond"), + ("tab", "a\tb"), + ] { + let toml = format!("[nodes.val1.el.env]\nFOO = {bad_value:?}\n"); + let err = Manifest::from_string(&toml).unwrap_err().to_string(); + assert!( + err.contains("control character"), + "{label} value should be rejected, got: {err}" + ); + } + } + + /// Per-node env survives the Manifest → RawManifest → TOML → Manifest round-trip. + /// The global env block is not retained separately, so it folds into each node. + #[test] + fn test_env_roundtrip() { + let toml = r#" + [el.env] + RUST_LOG = "info" + [nodes.val1.cl.env] + ARC_HALT_AT_BLOCK_HEIGHT = "100" + [nodes.val2] + "#; + + let manifest1 = Manifest::from_string(toml).unwrap(); + + let raw = RawManifest::try_from(manifest1).unwrap(); + let serialized = toml::to_string(&raw).unwrap(); + + let manifest2 = Manifest::from_string(&serialized).unwrap(); + // val1: inherited el RUST_LOG + its own cl ARC_HALT_AT_BLOCK_HEIGHT. + assert_eq!(manifest2.nodes["val1"].el_env["RUST_LOG"], "info"); + assert_eq!( + manifest2.nodes["val1"].cl_env["ARC_HALT_AT_BLOCK_HEIGHT"], + "100" + ); + // val2: inherited el RUST_LOG, no cl env. + assert_eq!(manifest2.nodes["val2"].el_env["RUST_LOG"], "info"); + assert!(manifest2.nodes["val2"].cl_env.is_empty()); + } } diff --git a/crates/quake/src/mcp.rs b/crates/quake/src/mcp.rs index 61e0c46d..959984bb 100644 --- a/crates/quake/src/mcp.rs +++ b/crates/quake/src/mcp.rs @@ -15,11 +15,12 @@ // limitations under the License. use std::collections::HashMap; +use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use color_eyre::eyre::Result; -use rmcp::handler::server::tool::ToolRouter; +use reqwest::Method; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ CallToolResult, Content, Implementation, ListResourcesResult, PaginatedRequestParams, @@ -31,14 +32,16 @@ use rmcp::tool; use rmcp::{ServerHandler, ServiceExt}; use schemars::JsonSchema; use serde::Deserialize; +use serde_json::Value; use tokio::sync::RwLock; use tracing::info; use crate::infra::remote; +use crate::node::NodeName; use crate::perturb::Perturbation; use crate::testnet::{Testnet, LAST_MANIFEST_FILENAME}; use crate::valset::ValidatorPowerUpdate; -use crate::{clean, rpc}; +use crate::{clean, rpc, rpc_cmd}; /// Overall timeout for RPC-based observability queries. Prevents tools from /// hanging when the proxy or SSM tunnel is degraded in remote mode. @@ -51,7 +54,6 @@ const SSH_TIMEOUT: Duration = Duration::from_secs(60); /// MCP server that exposes observability and management tools for a running Quake testnet. pub(crate) struct QuakeMcpServer { testnet: Arc>, - tool_router: ToolRouter, } #[rmcp::tool_router] @@ -893,7 +895,7 @@ impl QuakeMcpServer { }; match action { - crate::SSMSubcommand::Start => { + RemoteSsmAction::Start => { infra.ssm_tunnels.start().await.map_err(|e| { rmcp::ErrorData::internal_error( format!("Failed to start SSM tunnels: {e}"), @@ -904,7 +906,7 @@ impl QuakeMcpServer { "SSM tunnels started", )])) } - crate::SSMSubcommand::Stop => { + RemoteSsmAction::Stop => { infra.ssm_tunnels.stop().await.map_err(|e| { rmcp::ErrorData::internal_error( format!("Failed to stop SSM tunnels: {e}"), @@ -915,7 +917,7 @@ impl QuakeMcpServer { "SSM tunnels stopped", )])) } - crate::SSMSubcommand::List => { + RemoteSsmAction::List => { let output = infra.ssm_tunnels.list_formatted().map_err(|e| { rmcp::ErrorData::internal_error( format!("Failed to list SSM tunnels: {e}"), @@ -960,6 +962,180 @@ impl QuakeMcpServer { "Testnet files provisioned to Control Center", )])) } + + // ── RPC tools ─────────────────────────────────────────────────────── + + /// Returns the live CL endpoint catalog (fetched from one node) plus a + /// link to Reth's JSON-RPC documentation for EL methods. Useful before + /// invoking `rpc_el` or `rpc_cl` to discover what's available. + #[tool( + name = "rpc_list", + annotations(read_only_hint = true, open_world_hint = true) + )] + async fn rpc_list( + &self, + params: Parameters, + ) -> Result { + self.ensure_ssm_tunnels().await?; + let p = params.0; + let timeout = p + .timeout_secs + .map(Duration::from_secs) + .unwrap_or(RPC_TIMEOUT); + + let testnet = self.testnet.read().await; + let (node, base_url) = testnet.nodes_metadata.first_consensus_rpc_url(); + let catalog = rpc_cmd::fetch_cl_catalog(node, base_url, timeout) + .await + .map_err(|e| rmcp::ErrorData::internal_error(format!("rpc_list failed: {e}"), None))?; + + let cl_section = match catalog.endpoints { + Ok(value) => serde_json::json!({ + "node": catalog.node, + "url": catalog.url.to_string(), + "endpoints": value, + }), + Err(err) => serde_json::json!({ + "node": catalog.node, + "url": catalog.url.to_string(), + "error": err, + }), + }; + + let output = serde_json::json!({ + "cl": cl_section, + "el": { + "docs_url": rpc_cmd::RETH_JSONRPC_DOCS_URL, + "note": "EL JSON-RPC methods are documented at the Reth reference.", + }, + }); + + let text = serde_json::to_string_pretty(&output).map_err(|e| { + rmcp::ErrorData::internal_error( + format!("Failed to serialize rpc_list output: {e}"), + None, + ) + })?; + Ok(CallToolResult::success(vec![Content::text(text)])) + } + + /// Sends a JSON-RPC call to one or more EL (Reth) nodes in parallel. + /// Common methods: `admin_clearTxpool`, `eth_blockNumber`, + /// `eth_getBalance`, `txpool_status`, `debug_*`, `trace_*`. See + /// `rpc_list` for the docs URL. + /// + /// Targets default to all nodes. Use selectors like `["validator1"]`, + /// `["ALL_VALIDATORS"]`, or `["validator1","validator2"]`. Returns a + /// JSON array of per-node results; partial failures are reported per + /// node without failing the call. + #[tool( + name = "rpc_el", + annotations(read_only_hint = false, open_world_hint = true) + )] + async fn rpc_el( + &self, + params: Parameters, + ) -> Result { + self.ensure_ssm_tunnels().await?; + let p = params.0; + let timeout = p + .timeout_secs + .map(Duration::from_secs) + .unwrap_or(RPC_TIMEOUT); + let retries = p.retries.unwrap_or(0); + let params_value = Value::Array(p.params.unwrap_or_default()); + + let testnet = self.testnet.read().await; + let node_urls = testnet + .nodes_metadata + .resolve_el_targets(&testnet.manifest, p.targets.as_deref()) + .map_err(|e| rmcp::ErrorData::internal_error(format!("rpc_el failed: {e}"), None))?; + let outputs = rpc_cmd::fanout_el(node_urls, &p.method, params_value, timeout, retries) + .await + .map_err(|e| rmcp::ErrorData::internal_error(format!("rpc_el failed: {e}"), None))?; + + fanout_tool_result(outputs) + } + + /// Sends a REST call to one or more CL (Malachite) nodes in parallel. + /// Common paths: `/status`, `/consensus-state`, `/commit`, + /// `/network-state`, `/persistent-peers`. The leading `/` is optional. + /// Use `rpc_list` to discover available endpoints. + /// + /// Defaults to GET with no body; pass `http_method` and `body` for + /// POST/DELETE/PUT/PATCH. Returns a JSON array of per-node results. + #[tool( + name = "rpc_cl", + annotations(read_only_hint = false, open_world_hint = true) + )] + async fn rpc_cl( + &self, + params: Parameters, + ) -> Result { + self.ensure_ssm_tunnels().await?; + let p = params.0; + let timeout = p + .timeout_secs + .map(Duration::from_secs) + .unwrap_or(RPC_TIMEOUT); + let retries = p.retries.unwrap_or(0); + let http_method_str = p.http_method.as_deref().unwrap_or("GET").to_uppercase(); + let http_method = Method::from_str(&http_method_str).map_err(|_| { + rmcp::ErrorData::invalid_params( + format!("Invalid HTTP method '{http_method_str}'"), + None, + ) + })?; + + let body_string = p.body.map(|v| v.to_string()); + + let testnet = self.testnet.read().await; + let node_urls = testnet + .nodes_metadata + .resolve_cl_targets(&testnet.manifest, p.targets.as_deref()) + .map_err(|e| rmcp::ErrorData::internal_error(format!("rpc_cl failed: {e}"), None))?; + let outputs = rpc_cmd::fanout_cl( + node_urls, + &p.path, + &http_method, + body_string.as_deref(), + timeout, + retries, + ) + .await + .map_err(|e| rmcp::ErrorData::internal_error(format!("rpc_cl failed: {e}"), None))?; + + fanout_tool_result(outputs) + } +} + +/// Render fan-out results as a pretty-printed JSON array, one element per node. +fn format_fanout_results( + outputs: Vec<(NodeName, std::result::Result)>, +) -> Result { + let array: Vec = outputs + .into_iter() + .map(|(node, result)| match result { + Ok(value) => serde_json::json!({ "node": node, "result": value }), + Err(err) => serde_json::json!({ "node": node, "error": err }), + }) + .collect(); + serde_json::to_string_pretty(&Value::Array(array)).map_err(|e| { + rmcp::ErrorData::internal_error(format!("Failed to serialize results: {e}"), None) + }) +} + +fn fanout_tool_result( + outputs: Vec<(NodeName, std::result::Result)>, +) -> Result { + let all_failed = !outputs.is_empty() && outputs.iter().all(|(_, result)| result.is_err()); + let text = format_fanout_results(outputs)?; + let content = vec![Content::text(text)]; + Ok(if all_failed { + CallToolResult::error(content) + } else { + CallToolResult::success(content) + }) } // ── Parameter structs ─────────────────────────────────────────────────── @@ -1069,7 +1245,62 @@ struct RemoteSshParams { #[derive(Debug, Deserialize, JsonSchema)] struct RemoteSsmParams { /// Action to perform: "start", "stop", or "list". - action: crate::SSMSubcommand, + action: RemoteSsmAction, +} + +/// MCP-facing subset of SSM actions. The CLI's `keep-alive` variant is +/// intentionally excluded — it's a long-running command meant for an +/// interactive terminal, not an MCP tool call. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum RemoteSsmAction { + Start, + Stop, + List, +} + +/// Parameters for the rpc_list tool. +#[derive(Debug, Deserialize, JsonSchema)] +struct RpcListParams { + /// Per-node request timeout in seconds. Defaults to 15. + timeout_secs: Option, +} + +/// Parameters for the rpc_el tool. +#[derive(Debug, Deserialize, JsonSchema)] +struct RpcElParams { + /// JSON-RPC method name, e.g. "admin_clearTxpool", "eth_blockNumber". + method: String, + /// Target selectors. Each entry is a node name ("validator1") or a + /// manifest node group ("ALL_VALIDATORS", "ALL_NODES", + /// "ALL_NON_VALIDATORS"). Defaults to all nodes when omitted. + targets: Option>, + /// JSON-RPC params array. Each entry is sent verbatim as a JSON value. + /// Defaults to `[]`. + params: Option>, + /// Per-node request timeout in seconds. Defaults to 15. + timeout_secs: Option, + /// Per-node retry count on transport failure. Defaults to 0. + retries: Option, +} + +/// Parameters for the rpc_cl tool. +#[derive(Debug, Deserialize, JsonSchema)] +struct RpcClParams { + /// REST path; leading `/` is optional and prepended automatically. + /// May include a query string, e.g. "/commit?height=42". + path: String, + /// Target selectors. See `rpc_el.targets`. Defaults to all + /// consensus-enabled nodes when omitted. + targets: Option>, + /// HTTP method (GET, POST, DELETE, PUT, PATCH). Defaults to GET. + http_method: Option, + /// JSON body for POST/DELETE/PUT/PATCH. Sent verbatim as the request body. + body: Option, + /// Per-node request timeout in seconds. Defaults to 15. + timeout_secs: Option, + /// Per-node retry count on transport failure. Defaults to 0. + retries: Option, } // ── Helper methods ────────────────────────────────────────────────────── @@ -1095,11 +1326,8 @@ fn parse_duration_str(s: &str) -> Result { impl QuakeMcpServer { fn new(testnet: Testnet) -> Self { - let testnet = Arc::new(RwLock::new(testnet)); - let tool_router = Self::tool_router(); Self { - testnet, - tool_router, + testnet: Arc::new(RwLock::new(testnet)), } } @@ -1154,29 +1382,23 @@ impl QuakeMcpServer { #[rmcp::tool_handler] impl ServerHandler for QuakeMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: Default::default(), - capabilities: ServerCapabilities { - resources: Some(ResourcesCapability { + ServerInfo::new( + ServerCapabilities::builder() + .enable_resources_with(ResourcesCapability { subscribe: None, list_changed: Some(false), - }), - tools: Some(ToolsCapability { + }) + .enable_tools_with(ToolsCapability { list_changed: Some(false), - }), - ..Default::default() - }, - server_info: Implementation { - name: "quake-mcp-server".to_string(), - title: None, - version: arc_version::SHORT_VERSION.to_string(), - description: Some( - "MCP server for observing and managing Arc testnet state via Quake".to_string(), - ), - icons: None, - website_url: None, - }, - instructions: Some( + }) + .build(), + ) + .with_server_info( + Implementation::new("quake-mcp-server", arc_version::SHORT_VERSION).with_description( + "MCP server for observing and managing Arc testnet state via Quake", + ), + ) + .with_instructions( "This server provides tools for observing and managing a running Arc testnet via Quake.\n\n\ Observability: testnet_status (overview), list_nodes (node details), get_block_heights \ (chain heights), get_mempool (transaction pool), get_peers (network connectivity), \ @@ -1188,13 +1410,14 @@ impl ServerHandler for QuakeMcpServer { (upgrade to new image).\n\n\ Testing: run_tests (E2E tests), wait_height (wait for block height), \ valset_update (update validator voting power).\n\n\ + RPC fan-out: rpc_list (discover CL endpoints and EL docs link), \ + rpc_el (call any Reth JSON-RPC method on one or more nodes), \ + rpc_cl (call any Malachite REST endpoint on one or more nodes).\n\n\ Remote: remote_ssh (run a command on a node or CC via SSH), \ remote_ssm (manage SSM tunnels: start/stop/list), \ remote_provision (upload config files to CC). Remote testnets only.\n\n\ Resources: quake://manifest (testnet TOML config), quake://nodes (node metadata JSON)." - .to_string(), - ), - } + ) } fn list_resources( @@ -1247,9 +1470,10 @@ impl ServerHandler for QuakeMcpServer { None, ) })?; - Ok(ReadResourceResult { - contents: vec![ResourceContents::text(manifest_toml, "quake://manifest")], - }) + Ok(ReadResourceResult::new(vec![ResourceContents::text( + manifest_toml, + "quake://manifest", + )])) } "quake://nodes" => { let nodes_json = @@ -1259,9 +1483,10 @@ impl ServerHandler for QuakeMcpServer { None, ) })?; - Ok(ReadResourceResult { - contents: vec![ResourceContents::text(nodes_json, "quake://nodes")], - }) + Ok(ReadResourceResult::new(vec![ResourceContents::text( + nodes_json, + "quake://nodes", + )])) } other => Err(rmcp::ErrorData::invalid_params( format!("Unknown resource URI: {other}"), @@ -1272,19 +1497,7 @@ impl ServerHandler for QuakeMcpServer { } /// Entry point to start the MCP server. -/// -/// Supports two transport modes: -/// - stdio (default): for direct integration with Claude Code, Cursor, etc. -/// - HTTP+SSE (`--http`): for remote clients over the network -pub(crate) async fn run_server(testnet: Testnet, http: bool, port: u16) -> Result<()> { - if http { - run_http_server(testnet, port).await - } else { - run_stdio_server(testnet).await - } -} - -async fn run_stdio_server(testnet: Testnet) -> Result<()> { +pub(crate) async fn run_server(testnet: Testnet) -> Result<()> { info!("Starting MCP server on stdio transport"); let server = QuakeMcpServer::new(testnet); @@ -1304,42 +1517,57 @@ async fn run_stdio_server(testnet: Testnet) -> Result<()> { Ok(()) } -async fn run_http_server(testnet: Testnet, port: u16) -> Result<()> { - use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; - use rmcp::transport::StreamableHttpService; - - info!(port, "Starting MCP server on HTTP+SSE transport"); - - let testnet = Arc::new(RwLock::new(testnet)); +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; - let config = rmcp::transport::StreamableHttpServerConfig::default(); - let session_manager = Arc::new(LocalSessionManager::default()); - - let service = StreamableHttpService::new( - { - let testnet = Arc::clone(&testnet); - move || { - Ok(QuakeMcpServer { - testnet: Arc::clone(&testnet), - tool_router: QuakeMcpServer::tool_router(), - }) - } - }, - session_manager, - config, - ); + #[test] + fn format_fanout_results_emits_result_or_error_per_node() { + let outputs: Vec<(NodeName, std::result::Result)> = vec![ + ("validator1".to_string(), Ok(json!(42))), + ( + "validator2".to_string(), + Err("connection refused".to_string()), + ), + ]; + let rendered = format_fanout_results(outputs).unwrap(); + let parsed: Value = serde_json::from_str(&rendered).unwrap(); + let arr = parsed.as_array().expect("array"); + assert_eq!(arr.len(), 2); + + assert_eq!(arr[0], json!({"node": "validator1", "result": 42})); + assert!(arr[0].get("error").is_none()); + + assert_eq!( + arr[1], + json!({"node": "validator2", "error": "connection refused"}) + ); + assert!(arr[1].get("result").is_none()); + } - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) - .await - .map_err(|e| color_eyre::eyre::eyre!("Failed to bind to port {port}: {e}"))?; + #[test] + fn format_fanout_results_empty_input_returns_empty_array() { + let rendered = format_fanout_results(Vec::new()).unwrap(); + let parsed: Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(parsed, json!([])); + } - info!(port, "MCP HTTP+SSE server listening"); + #[test] + fn fanout_tool_result_marks_all_node_failures_as_error() { + let outputs: Vec<(NodeName, std::result::Result)> = vec![ + ( + "validator1".to_string(), + Err("connection refused".to_string()), + ), + ( + "validator2".to_string(), + Err("request timed out".to_string()), + ), + ]; - let app = axum::Router::new().fallback_service(service); - axum::serve(listener, app) - .await - .map_err(|e| color_eyre::eyre::eyre!("MCP HTTP server error: {e}"))?; + let result = fanout_tool_result(outputs).unwrap(); - info!("MCP HTTP server stopped"); - Ok(()) + assert_eq!(result.is_error, Some(true)); + } } diff --git a/crates/quake/src/metrics.rs b/crates/quake/src/metrics.rs new file mode 100644 index 00000000..f3f76a07 --- /dev/null +++ b/crates/quake/src/metrics.rs @@ -0,0 +1,206 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Mode-agnostic Prometheus metrics download for Quake testnets. +//! +//! Queries the Prometheus `query_range` API directly over HTTP at +//! `prometheus_url` and bundles the JSON responses into a `.tar.gz`. +//! Local testnets expose Prometheus on a host port via Docker; remote +//! testnets forward the same local port to CC via SSM tunnel, so the +//! same code path handles both without infra-specific dispatch. +//! +//! Defaults match what `download-metrics.sh` did on CC, so this function is +//! a drop-in replacement for `RemoteInfra::download_metrics`. + +use std::path::Path; +use std::time::Duration; + +use color_eyre::eyre::{eyre, Result, WrapErr}; +use tracing::{info, warn}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Query Prometheus and bundle each metric's response into a tarball at `dest`. +/// +/// `metric_names` empty → fetch every metric name reported by +/// `/api/v1/label/__name__/values` (matches the old shell script). +/// `from` `None` → use Prometheus' `headStats.minTime` (current head block start). +/// `to` `None` → now. +/// `step` `None` → ceil((to - from)/10 000) seconds, bounded ≥1 (keeps the +/// response below Prometheus' 11 000-point limit). +/// +/// Per-metric query failures are logged at warn and skipped — the archive is +/// always produced even if some series aren't currently available. +pub(crate) async fn download_to_tarball( + prometheus_url: &str, + metric_names: &[&str], + from: Option, + to: Option, + step: Option<&str>, + dest: &Path, +) -> Result<()> { + // User-supplied names are interpolated into the response filename, so + // reject anything outside the Prometheus identifier alphabet to prevent + // path traversal (e.g. `../../etc/passwd`) and PromQL expressions + // (e.g. `rate(http_requests_total[5m])`) that wouldn't make a valid file. + for name in metric_names { + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':') + { + return Err(eyre!( + "Invalid metric name '{name}': must match [a-zA-Z0-9_:]+" + )); + } + } + + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build()?; + + let now = chrono::Utc::now().timestamp(); + let to = to.unwrap_or(now); + let from = match from { + Some(f) => f, + None => match query_head_min_time(&client, prometheus_url).await { + Ok(t) => t, + Err(err) => { + warn!(%err, "Failed to read Prometheus headStats.minTime; falling back to epoch 0 (archive may cover an unexpectedly sparse range)"); + 0 + } + }, + }; + + let step_owned; + let step = match step { + Some(s) => s, + None => { + step_owned = auto_step(from, to); + &step_owned + } + }; + + let names_owned: Vec; + let names: Vec<&str> = if metric_names.is_empty() { + names_owned = list_all_metrics(&client, prometheus_url) + .await + .wrap_err("list metric names")?; + names_owned.iter().map(String::as_str).collect() + } else { + metric_names.to_vec() + }; + + let tmp = tempfile::tempdir().wrap_err("create metrics temp dir")?; + let url = format!("{prometheus_url}/api/v1/query_range"); + let mut written: usize = 0; + for name in &names { + let body = match client + .get(&url) + .query(&[ + ("query", *name), + ("start", &from.to_string()), + ("end", &to.to_string()), + ("step", step), + ]) + .send() + .await + .and_then(|r| r.error_for_status()) + { + Ok(resp) => match resp.text().await { + Ok(t) => t, + Err(e) => { + warn!("metric query failed: {name}: read body: {e}"); + continue; + } + }, + Err(e) => { + warn!("metric query failed: {name}: {e}"); + continue; + } + }; + std::fs::write(tmp.path().join(format!("{name}.json")), body) + .wrap_err_with(|| format!("write {name}.json"))?; + written = written.saturating_add(1); + } + if written == 0 { + warn!( + "Metrics tarball will be empty: all {} queries against {prometheus_url} failed (or no series were available)", + names.len() + ); + } + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .wrap_err_with(|| format!("create output dir {}", parent.display()))?; + } + // Shell out to `tar` to avoid pulling in flate2/gzip crates for a one-time use. + let status = std::process::Command::new("tar") + .arg("czf") + .arg(dest) + .arg("-C") + .arg(tmp.path()) + .arg(".") + .status() + .wrap_err("run tar")?; + if !status.success() { + return Err(eyre!("tar exited with {status}")); + } + info!(path = %dest.display(), "Metrics downloaded"); + Ok(()) +} + +/// `(to - from)/10 000` seconds, ≥ 1 — matches the old shell script formula. +fn auto_step(from: i64, to: i64) -> String { + let span = (to - from).max(1); + let s = ((span + 9_999) / 10_000).max(1); + format!("{s}s") +} + +/// Read Prometheus' current head-block start time (seconds since epoch). +async fn query_head_min_time(client: &reqwest::Client, prometheus_url: &str) -> Result { + let url = format!("{prometheus_url}/api/v1/status/tsdb"); + let resp: serde_json::Value = client + .get(&url) + .send() + .await? + .error_for_status()? + .json() + .await?; + let min_ms = resp["data"]["headStats"]["minTime"] + .as_i64() + .ok_or_else(|| eyre!("missing data.headStats.minTime in /status/tsdb response"))?; + Ok(min_ms / 1_000) +} + +/// List every metric name Prometheus currently knows about. +async fn list_all_metrics(client: &reqwest::Client, prometheus_url: &str) -> Result> { + let url = format!("{prometheus_url}/api/v1/label/__name__/values"); + let resp: serde_json::Value = client + .get(&url) + .send() + .await? + .error_for_status()? + .json() + .await?; + let arr = resp["data"] + .as_array() + .ok_or_else(|| eyre!("missing data array in /label/__name__/values response"))?; + Ok(arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect()) +} diff --git a/crates/quake/src/node.rs b/crates/quake/src/node.rs index 4274cef9..9f7729db 100644 --- a/crates/quake/src/node.rs +++ b/crates/quake/src/node.rs @@ -14,6 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use color_eyre::eyre::{bail, Result}; use indexmap::IndexMap; use serde::Serialize; use std::ops::{Deref, DerefMut}; @@ -233,6 +234,9 @@ pub(crate) struct ConsensusContainer { /// CLI flags for starting the upgraded consensus layer #[serde(skip_serializing_if = "Vec::is_empty")] pub cli_flags_upgraded: Vec, + + /// Effective consensus layer image for this node's base container. + pub image: String, } impl ConsensusContainer { @@ -260,12 +264,13 @@ impl ConsensusContainer { metrics_url, cli_flags: Vec::new(), cli_flags_upgraded: Vec::new(), + image: String::new(), } } /// Create a new ConsensusContainer with explicit network IPs (remote mode) pub fn new_remote(node: &NodeName, subnets: &SubnetIps) -> Self { - let rpc_url = Url::parse(&format!("http://127.0.0.1:{RPC_PROXY_SSM_PORT}/{node}/cl")) + let rpc_url = Url::parse(&format!("http://127.0.0.1:{RPC_PROXY_SSM_PORT}/{node}/cl/")) .expect("Failed to parse RPC URL"); let metrics_url = Url::parse(&format!( "http://127.0.0.1:{RPC_PROXY_SSM_PORT}/{node}/cl/metrics" @@ -281,6 +286,7 @@ impl ConsensusContainer { metrics_url, cli_flags: Vec::new(), cli_flags_upgraded: Vec::new(), + image: String::new(), } } @@ -318,6 +324,9 @@ pub(crate) struct ExecutionContainer { /// Execution layer (Reth) CLI flags for this container pub(crate) cli_flags: Vec, + /// Effective execution layer image for this node's base container. + pub image: String, + /// Exposed ports pub http_port: usize, pub ws_port: usize, @@ -352,6 +361,7 @@ impl ExecutionContainer { http_url: Url::parse(http_url).expect("Failed to parse HTTP URL"), ws_url: Url::parse(ws_url).expect("Failed to parse WS URL"), cli_flags, + image: String::new(), } } @@ -373,6 +383,7 @@ impl ExecutionContainer { http_url: Url::parse(http_url).expect("Failed to parse HTTP URL"), ws_url: Url::parse(ws_url).expect("Failed to parse WS URL"), cli_flags, + image: String::new(), } } } @@ -418,6 +429,12 @@ pub(crate) struct NodeMetadata { /// Whether consensus is enabled for this node (default true) /// When false, the node only syncs and doesn't participate in consensus pub consensus_enabled: bool, + + /// Environment variables for the execution layer (Reth) container. + pub el_env: IndexMap, + + /// Environment variables for the consensus layer (Malachite) container. + pub cl_env: IndexMap, } impl NodeMetadata { @@ -452,6 +469,8 @@ impl NodeMetadata { follow, follow_endpoints, consensus_enabled, + el_env: IndexMap::new(), + cl_env: IndexMap::new(), } } @@ -490,6 +509,8 @@ impl NodeMetadata { follow, follow_endpoints, consensus_enabled, + el_env: IndexMap::new(), + cl_env: IndexMap::new(), } } @@ -499,10 +520,19 @@ impl NodeMetadata { } /// The names of the running CL and EL containers - pub fn container_names(&self) -> Vec { + pub fn running_container_names(&self) -> Vec { vec![ self.consensus.name().to_string(), self.execution.name().to_string(), ] } + + /// Resolve the name of this node's running CL or EL container by suffix + pub fn running_container_name(&self, suffix: &str) -> Result<&ContainerName> { + match suffix { + CONSENSUS_SUFFIX => Ok(self.consensus.name()), + EXECUTION_SUFFIX => Ok(self.execution.name()), + _ => bail!("unsupported container suffix '{suffix}'"), + } + } } diff --git a/crates/quake/src/nodes.rs b/crates/quake/src/nodes.rs index 79870ed8..a49fd533 100644 --- a/crates/quake/src/nodes.rs +++ b/crates/quake/src/nodes.rs @@ -15,10 +15,11 @@ // limitations under the License. use crate::infra::{InfraData, InfraType}; -use crate::manifest::{self, Subnets}; +use crate::manifest::{self, Manifest, Subnets}; use crate::node::{Container, ContainerName, IpAddress, NodeMetadata, NodeName, EXECUTION_SUFFIX}; +use crate::testnet; use color_eyre::eyre::{bail, eyre, Context, Result}; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use regex::Regex; use serde::Serialize; use std::collections::{BTreeSet, HashSet}; @@ -40,7 +41,10 @@ pub(crate) struct NodesMetadata { impl NodesMetadata { /// Create a new `NodesMetadata` instance from the given `InfraData`, - /// `manifest_nodes`, and `upgraded_containers`. + /// `manifest`, resolved `base_images`, and `upgraded_containers`. + /// + /// `base_images` are the network-wide resolved images; a node uses them + /// unless the manifest set a per-node override. /// /// `upgraded_containers` tracks which containers have been upgraded so they /// persist across Quake restarts (e.g., `quake stop` followed by `quake start` @@ -49,6 +53,7 @@ impl NodesMetadata { pub fn new( infra_data: InfraData, manifest: &manifest::Manifest, + base_images: &testnet::DockerImages, upgraded_containers: &BTreeSet, ) -> Result { // Remote mode before provision: infra_data has no nodes yet; return empty so @@ -100,7 +105,7 @@ impl NodesMetadata { .filter_map(|endpoint_name| node_to_el_url.get(endpoint_name).cloned()) .collect(); - let consensus_enabled = manifest_node.cl_config.consensus_enabled(); + let consensus_enabled = !manifest_node.cl_config.no_consensus; let mut node = match infra_data.infra_type { InfraType::Local => { @@ -128,6 +133,25 @@ impl NodesMetadata { ), }; + node.el_env = manifest_node.el_env.clone(); + node.cl_env = manifest_node.cl_env.clone(); + + // Effective per-node images: the manifest override (inline or group, + // already flattened in TryFrom) falls back to the resolved global image. + let (image_cl, image_el) = + manifest_node.effective_images(&base_images.cl, &base_images.el); + if matches!(infra_data.infra_type, InfraType::Remote) { + for img in [&image_cl, &image_el] { + if !img.starts_with("ghcr.io/") { + bail!( + "Image '{img}' for node '{name}' must start with 'ghcr.io/' for remote mode" + ); + } + } + } + node.consensus.image = image_cl; + node.execution.image = image_el; + // Mark containers that have been upgraded if upgraded_containers.contains(&node.consensus.name) { node.consensus.upgrade(); @@ -149,6 +173,19 @@ impl NodesMetadata { self.nodes.len() } + /// Distinct effective execution-layer and consensus-layer images across all + /// nodes, in first-seen order. Returned as `(el_images, cl_images)` so build + /// and pull paths cover per-node overrides, not just the global images. + pub fn distinct_images(&self) -> (Vec, Vec) { + let mut el = IndexSet::new(); + let mut cl = IndexSet::new(); + for node in self.nodes.values() { + el.insert(node.execution.image.clone()); + cl.insert(node.consensus.image.clone()); + } + (el.into_iter().collect(), cl.into_iter().collect()) + } + pub fn node_names(&self) -> Vec { self.nodes.keys().cloned().collect() } @@ -272,6 +309,10 @@ impl NodesMetadata { peer_meta.execution.private_ip_address_for(subnet) } + pub fn consensus_rpc_url(&self, node: &str) -> Option { + self.nodes.get(node).map(|n| n.consensus.rpc_url.clone()) + } + pub fn execution_http_url(&self, node: &str) -> Option { self.nodes.get(node).map(|n| n.execution.http_url.clone()) } @@ -287,6 +328,13 @@ impl NodesMetadata { .collect() } + pub fn to_consensus_rpc_urls(&self, nodes: &[NodeName]) -> Vec<(NodeName, Url)> { + nodes + .iter() + .map(|name| (name.clone(), self.consensus_rpc_url(name).unwrap())) + .collect() + } + pub fn to_execution_ws_urls(&self, node_names: &[NodeName]) -> Vec<(NodeName, Url)> { node_names .iter() @@ -314,11 +362,12 @@ impl NodesMetadata { .map(|(name, n)| (name.clone(), n.consensus.metrics_url.clone())) .collect() } + /// The list of consensus layer RPC URLs for nodes with consensus enabled. /// Nodes with `consensus_enabled: false` (sync-only followers) are excluded. /// In local mode, ports are mapped to 127.0.0.1 with per-node offsets. /// In remote mode, URLs use the node's private IP. - pub fn all_consensus_rpc_urls(&self) -> Vec<(NodeName, Url)> { + pub fn all_consensus_enabled_rpc_urls(&self) -> Vec<(NodeName, Url)> { self.nodes .iter() .filter(|(_, n)| n.consensus_enabled) @@ -326,6 +375,38 @@ impl NodesMetadata { .collect() } + /// CL RPC URL of the first node in the manifest. + /// Useful for single-node CL reads (e.g. the endpoint catalog) where any consensus will do. + pub fn first_consensus_rpc_url(&self) -> (NodeName, Url) { + let (name, node) = self + .nodes + .first() + .expect("There should always be at least one node in a testnet"); + (name.clone(), node.consensus.rpc_url.clone()) + } + + /// Resolve the selectors against the manifest and return each matching + /// node paired with its EL JSON-RPC URL. + pub fn resolve_el_targets( + &self, + manifest: &Manifest, + selectors: Option<&[String]>, + ) -> Result> { + let node_names = manifest.resolve_optional_node_selectors(selectors)?; + Ok(self.to_execution_http_urls(&node_names)) + } + + /// Resolve target selectors against the manifest and return each matching + /// node paired with its CL RPC URL. + pub fn resolve_cl_targets( + &self, + manifest: &Manifest, + selectors: Option<&[String]>, + ) -> Result> { + let node_names = manifest.resolve_optional_node_selectors(selectors)?; + Ok(self.to_consensus_rpc_urls(&node_names)) + } + /// Serialize node metadata for use on the Control Center. /// /// The in-memory URLs point at the SSM tunnel (port [`RPC_PROXY_SSM_PORT`]) @@ -356,10 +437,21 @@ impl NodesMetadata { pub fn all_container_names(&self) -> Vec { self.nodes .values() - .flat_map(|n| n.container_names()) + .flat_map(|n| n.running_container_names()) .collect() } + /// Resolve the name of a node's running CL or EL container from metadata. + /// + /// Looks up `node` and delegates to [`NodeMetadata::running_container_name`], + /// which honors the `_u` suffix applied to upgraded containers. + pub fn running_container_name(&self, node: &NodeName, suffix: &str) -> Result { + let meta = self + .get(node) + .ok_or_else(|| eyre!("node '{node}' not found in metadata"))?; + Ok(meta.running_container_name(suffix)?.clone()) + } + /// Convert a list of container names to a list of containers pub fn to_containers(&self, names: &[ContainerName]) -> Vec<&Container> { let all_containers = self.nodes.values().flat_map(|n| n.containers()); @@ -393,13 +485,13 @@ impl NodesMetadata { if !name.contains('*') { // If the name is a node name, return its containers if let Some(node) = self.nodes.get(name) { - return Ok(node.container_names()); + return Ok(node.running_container_names()); } // If the name is a container name, return it if self .nodes .values() - .any(|node| node.container_names().contains(name)) + .any(|node| node.running_container_names().contains(name)) { return Ok(vec![name.to_string()]); } @@ -414,10 +506,10 @@ impl NodesMetadata { for (name, node) in self.nodes.iter() { // Check if node name matches if regex.is_match(name) { - matches.extend(node.container_names()); + matches.extend(node.running_container_names()); } else { // Check if container names match - for container in node.container_names() { + for container in node.running_container_names() { if regex.is_match(&container) { matches.push(container); } @@ -527,7 +619,74 @@ mod tests { let testnet_name = Some("testnet".to_string()); let manifest = Manifest::new(testnet_name, &manifest_nodes, &node_subnets); - NodesMetadata::new(infra_data, &manifest, &BTreeSet::new()).unwrap() + NodesMetadata::new( + infra_data, + &manifest, + &manifest.images.to_local().unwrap(), + &BTreeSet::new(), + ) + .unwrap() + } + + #[test] + fn effective_images_use_override_then_global() { + let manifest = Manifest::from_string( + r#" + image_cl = "arc_consensus:global" + image_el = "arc_execution:global" + [nodes.validator1] + image_cl = "arc_consensus:pinned" + [nodes.validator2] + "#, + ) + .unwrap(); + let infra_data = InfraData::new_local("testnet".to_string(), &manifest.nodes); + let base = manifest.images.to_local().unwrap(); + let md = NodesMetadata::new(infra_data, &manifest, &base, &BTreeSet::new()).unwrap(); + + // validator1: CL pinned; EL falls back to the global image. + assert_eq!( + md.nodes["validator1"].consensus.image, + "arc_consensus:pinned" + ); + assert_eq!( + md.nodes["validator1"].execution.image, + "arc_execution:global" + ); + // validator2: no override, both fall back to global. + assert_eq!( + md.nodes["validator2"].consensus.image, + "arc_consensus:global" + ); + assert_eq!( + md.nodes["validator2"].execution.image, + "arc_execution:global" + ); + } + + #[test] + fn remote_rejects_non_ghcr_per_node_image() { + let manifest = Manifest::from_string( + r#" + [nodes.validator1] + image_el = "docker.io/foo/el:1" + "#, + ) + .unwrap(); + // Reuse the populated local infra, then mark it remote so the ghcr guard runs. + let mut infra_data = InfraData::new_local("testnet".to_string(), &manifest.nodes); + infra_data.infra_type = InfraType::Remote; + let base = testnet::DockerImages { + cl: "ghcr.io/org/cl:1".to_string(), + el: "ghcr.io/org/el:1".to_string(), + cl_upgrade: None, + el_upgrade: None, + }; + let err = NodesMetadata::new(infra_data, &manifest, &base, &BTreeSet::new()).unwrap_err(); + assert!( + err.to_string().contains("ghcr.io/"), + "unexpected error: {err}" + ); } /// Build metadata where `source` shares subnet B with `reachable`, diff --git a/crates/quake/src/perturb.rs b/crates/quake/src/perturb.rs index 80b439e3..3be8d114 100644 --- a/crates/quake/src/perturb.rs +++ b/crates/quake/src/perturb.rs @@ -335,10 +335,15 @@ async fn apply_kill( let containers_str = containers.join(", "); info!("💀 Killing and waiting {time_off:?}: {containers_str}"); - infra.kill(containers)?; + // Kill every target, then always restart them; only a failed restart is fatal. + if let Err(err) = infra.kill(containers) { + warn!("Some containers could not be killed, restarting them anyway: {err:#}"); + } debug!("Waiting {time_off:?} before restarting..."); tokio::time::sleep(time_off).await; - infra.start(containers)?; + infra + .start(containers) + .wrap_err_with(|| format!("failed to restart containers after kill: {containers_str}"))?; info!("💀 Killed and restarted after {time_off:?}: {containers_str}"); Ok(()) @@ -600,8 +605,131 @@ pub(crate) async fn save_upgraded_containers_set>( #[cfg(test)] mod tests { use super::*; + use crate::infra::BuildProfile; + use crate::node::NodeName; + use std::sync::Mutex; use std::{env, fs}; + /// In-memory `InfraProvider` that records the order of `kill`/`start` calls + /// and returns configurable success/failure for each. + struct RecordingInfra { + kill_ok: bool, + start_ok: bool, + calls: Mutex>, + } + + impl RecordingInfra { + fn new(kill_ok: bool, start_ok: bool) -> Self { + Self { + kill_ok, + start_ok, + calls: Mutex::new(Vec::new()), + } + } + + fn calls(&self) -> Vec<&'static str> { + self.calls.lock().unwrap().clone() + } + } + + impl InfraProvider for RecordingInfra { + fn kill(&self, _containers: &[ContainerName]) -> Result<()> { + self.calls.lock().unwrap().push("kill"); + if self.kill_ok { + Ok(()) + } else { + bail!("kill failed") + } + } + + fn start(&self, _targets: &[NodeOrContainerName]) -> Result<()> { + self.calls.lock().unwrap().push("start"); + if self.start_ok { + Ok(()) + } else { + bail!("start failed") + } + } + + fn build(&self, _profile: BuildProfile) -> Result<()> { + Ok(()) + } + fn is_setup(&self, _nodes: &[NodeName]) -> Result<()> { + Ok(()) + } + fn stop(&self, _targets: &[NodeOrContainerName]) -> Result<()> { + Ok(()) + } + fn down(&self, _targets: &[NodeOrContainerName]) -> Result<()> { + Ok(()) + } + fn logs(&self, _containers: &[NodeOrContainerName], _follow: bool) -> Result<()> { + Ok(()) + } + fn disconnect(&self, _containers_subnets: &[(&Container, &[&SubnetName])]) -> Result<()> { + Ok(()) + } + fn connect(&self, _containers_subnets: &[(&Container, &[&SubnetName])]) -> Result<()> { + Ok(()) + } + fn pause(&self, _containers: &[ContainerName]) -> Result<()> { + Ok(()) + } + fn unpause(&self, _containers: &[ContainerName]) -> Result<()> { + Ok(()) + } + fn restart(&self, _containers: &[ContainerName]) -> Result<()> { + Ok(()) + } + fn start_monitoring(&self) -> Result<()> { + Ok(()) + } + fn stop_monitoring(&self) -> Result<()> { + Ok(()) + } + fn clean_monitoring_data(&self) -> Result<()> { + Ok(()) + } + } + + async fn run_apply_kill(infra: &RecordingInfra) -> Result<()> { + let mut rng = StdRng::seed_from_u64(0); + let containers = vec!["validator1_cl".to_string()]; + apply_kill( + infra, + &containers, + Some(Duration::ZERO), + Duration::ZERO, + Duration::ZERO, + &mut rng, + ) + .await + } + + #[tokio::test] + async fn apply_kill_restarts_targets_when_kill_reports_errors() { + let infra = RecordingInfra::new(false, true); + let result = run_apply_kill(&infra).await; + assert!(result.is_ok()); + assert_eq!(infra.calls(), vec!["kill", "start"]); + } + + #[tokio::test] + async fn apply_kill_fails_when_restart_fails() { + let infra = RecordingInfra::new(true, false); + let result = run_apply_kill(&infra).await; + assert!(result.is_err()); + assert_eq!(infra.calls(), vec!["kill", "start"]); + } + + #[tokio::test] + async fn apply_kill_succeeds_when_kill_and_restart_succeed() { + let infra = RecordingInfra::new(true, true); + let result = run_apply_kill(&infra).await; + assert!(result.is_ok()); + assert_eq!(infra.calls(), vec!["kill", "start"]); + } + #[test] fn filter_upgraded_containers_all_upgraded() { let upgraded_suffix = format!("_{}", UPGRADED_SUFFIX); diff --git a/crates/quake/src/rpc/mod.rs b/crates/quake/src/rpc/mod.rs index 200ec84d..69d40e26 100644 --- a/crates/quake/src/rpc/mod.rs +++ b/crates/quake/src/rpc/mod.rs @@ -42,7 +42,12 @@ pub(crate) struct RpcClient { impl RpcClient { pub(crate) fn new(url: Url, timeout: Duration) -> Self { - let client = Client::new(); + Self::with_client(Client::new(), url, timeout) + } + + /// Reuse an externally built `reqwest::Client` (sharing its connection + /// pool / TLS context) across many `RpcClient` instances. + pub(crate) fn with_client(client: Client, url: Url, timeout: Duration) -> Self { Self { client, url, @@ -206,6 +211,22 @@ impl RpcClient { } } + /// Get a transaction by hash, pending or mined. + /// Returns `None` if the node has no knowledge of the transaction. + pub async fn get_transaction_by_hash( + &self, + tx_hash: &str, + ) -> Result> { + let response = self + .rpc_request::("eth_getTransactionByHash", json!([tx_hash]), 0) + .await?; + if response.is_null() { + Ok(None) + } else { + Ok(Some(response)) + } + } + // queries the getValidator() function in the validator registry smart contract. pub(crate) async fn get_validator( &self, diff --git a/crates/quake/src/rpc_cmd.rs b/crates/quake/src/rpc_cmd.rs new file mode 100644 index 00000000..6b4f696e --- /dev/null +++ b/crates/quake/src/rpc_cmd.rs @@ -0,0 +1,573 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! `quake rpc` — call EL JSON-RPC or CL REST endpoints on one or more nodes. +//! +//! The module is structured as a thin CLI on top of reusable fan-out helpers +//! (`fanout_el`, `fanout_cl`, `fetch_cl_catalog`) shared with the MCP server. +//! See `crates/quake/README.md` for the full command reference. + +use clap::{Args, ValueEnum}; +use color_eyre::eyre::{bail, eyre, Result, WrapErr}; +use reqwest::header::CONTENT_TYPE; +use reqwest::{Client, Method}; +use serde_json::Value; +use std::str::FromStr; +use std::time::Duration; +use tracing::warn; +use url::Url; + +use crate::node::NodeName; +use crate::parse_duration; +use crate::util; + +/// Reth's published JSON-RPC reference. Surfaced from `quake rpc list` (and +/// the MCP `rpc_list` tool) because the EL side does not expose a per-method +/// introspection endpoint. +pub(crate) const RETH_JSONRPC_DOCS_URL: &str = "https://reth.rs/jsonrpc/intro"; + +// ---------- CLI args -------------------------------------------------------- + +#[derive(Args)] +pub(crate) struct ElArgs { + /// JSON-RPC method name, e.g. `admin_clearTxpool`. + pub method: String, + + /// Positional slots after the method: `[target] [params...]`. + /// + /// - Zero entries: target defaults to ALL_NODES, no params. + /// - One entry: target only (no params). + /// - Two or more: first entry is the target, the rest are params + /// (auto-promoted via `serde_json::from_str`; otherwise quoted as string). + /// + /// The target is a comma-separated list of node names or manifest node + /// groups (e.g. `validator1,ALL_VALIDATORS`). To pass params with the + /// default target set, write `ALL_NODES` explicitly. + #[arg(value_name = "TARGET|PARAM")] + pub positional: Vec, + + /// Raw JSON array of params. Mutually exclusive with positional params. + #[arg(long, value_name = "JSON")] + pub raw: Option, + + #[command(flatten)] + pub common: CommonArgs, +} + +#[derive(Args)] +pub(crate) struct ClArgs { + /// REST path. A leading `/` is optional and prepended automatically. + /// May include a query string. Examples: `/status`, `commit`, + /// `/commit?height=42`. + pub path: String, + + /// Comma-separated targets (node names or manifest groups). Defaults to + /// all consensus-enabled nodes. + #[arg(value_name = "TARGET")] + pub target: Option, + + /// HTTP method (GET, POST, DELETE, PUT, PATCH). + #[arg(long, default_value = "GET")] + pub method: String, + + /// JSON body for POST/DELETE/PUT/PATCH. + #[arg(long, value_name = "JSON")] + pub body: Option, + + #[command(flatten)] + pub common: CommonArgs, +} + +impl ElArgs { + /// Split positional args into `(target, params)` using the rule that the + /// target slot must be present whenever params are present. + pub(crate) fn parse_positionals(&self) -> Result<(Option, Vec)> { + match self.positional.len() { + 0 => Ok((None, Vec::new())), + 1 => Ok((Some(self.positional[0].clone()), Vec::new())), + _ => { + if self.raw.is_some() { + bail!( + "--raw cannot be combined with positional params. \ + Pass only `` followed by --raw ''" + ); + } + Ok(( + Some(self.positional[0].clone()), + self.positional[1..].to_vec(), + )) + } + } + } +} + +#[derive(Args, Clone)] +pub(crate) struct CommonArgs { + /// Per-node request timeout. Default 10s. Accepts e.g. `30s`, `1m`. + #[arg(long, default_value = "10s", value_parser = parse_duration)] + pub timeout: Duration, + + /// Number of retries per node on transport failure. + #[arg(long, default_value = "0")] + pub retries: u32, + + /// Output format. + #[arg(long, value_enum, default_value_t = OutputFormat::Json)] + pub format: OutputFormat, +} + +#[derive(Clone, Copy, ValueEnum)] +pub(crate) enum OutputFormat { + /// Newline-delimited JSON, one object per node. + Json, + /// Two-column NODE | RESULT table, sorted by node name. + Table, + /// Result value only. Requires a single target. + Raw, +} + +// ---------- main functions ------------------------------------------------------------ + +pub(crate) async fn run_list(node: NodeName, base_url: Url) -> Result<()> { + let catalog = fetch_cl_catalog(node, base_url, Duration::from_secs(10)).await?; + + println!( + "Consensus Layer (REST) — endpoints on {} ({}):", + catalog.node, catalog.url + ); + match catalog.endpoints { + Ok(value) => println!("{}", serde_json::to_string_pretty(&value)?), + Err(err) => { + warn!(%err, "Failed to fetch CL endpoint catalog"); + println!(" (unreachable: {err})"); + } + } + + println!(); + println!("Execution Layer (JSON-RPC) — methods documented at:"); + println!(" {RETH_JSONRPC_DOCS_URL}"); + Ok(()) +} + +pub(crate) async fn run_el(node_urls: Vec<(NodeName, Url)>, args: ElArgs) -> Result<()> { + let (_target, params) = args.parse_positionals()?; + + let params_value = match args.raw { + Some(raw) => serde_json::from_str::(&raw) + .wrap_err("--raw must be a valid JSON value (typically an array)")?, + None => Value::Array(params.into_iter().map(promote_param).collect()), + }; + + let outputs = fanout_el( + node_urls, + &args.method, + params_value, + args.common.timeout, + args.common.retries, + ) + .await?; + + render_and_exit(outputs, args.common.format) +} + +/// Matches `cast rpc` param semantics: JSON literals keep their type, anything +/// else is treated as a string. +fn promote_param(raw: String) -> Value { + serde_json::from_str::(&raw).unwrap_or(Value::String(raw)) +} + +pub(crate) async fn run_cl(node_urls: Vec<(NodeName, Url)>, args: ClArgs) -> Result<()> { + let method = Method::from_str(&args.method.to_uppercase()) + .map_err(|_| eyre!("Invalid HTTP method '{}'", args.method))?; + + let outputs = fanout_cl( + node_urls, + &args.path, + &method, + args.body.as_deref(), + args.common.timeout, + args.common.retries, + ) + .await?; + + render_and_exit(outputs, args.common.format) +} + +// ---------- output rendering (CLI only) ------------------------------------- + +/// Print collected outputs in the requested format and return an error if any +/// node failed so the process exits non-zero. +fn render_and_exit( + outputs: Vec<(NodeName, Result)>, + format: OutputFormat, +) -> Result<()> { + match format { + OutputFormat::Raw => return render_raw(outputs), + OutputFormat::Json => { + for (node, result) in &outputs { + println!( + "{}", + serde_json::to_string(&node_output_json(node, result))? + ); + } + } + OutputFormat::Table => print_table(&outputs), + } + + let failed = outputs.iter().filter(|(_, r)| r.is_err()).count(); + if failed > 0 { + bail!("{failed}/{} node(s) failed", outputs.len()); + } + Ok(()) +} + +/// Build the per-node JSON envelope used by `--format json` and the MCP +/// equivalents. Exactly one of `result` / `error` is present, mirroring the +/// `Result` it came from. +fn node_output_json(node: &str, result: &Result) -> Value { + match result { + Ok(v) => serde_json::json!({"node": node, "result": v}), + Err(e) => serde_json::json!({"node": node, "error": e}), + } +} + +/// Render the single-target `--format raw` case. The array conversion encodes +/// the "exactly one target" invariant in the type system, so this function +/// cannot reach an empty-or-multi state by mistake. +fn render_raw(outputs: Vec<(NodeName, Result)>) -> Result<()> { + let len = outputs.len(); + let array: [(NodeName, Result); 1] = outputs + .try_into() + .map_err(|_| eyre!("--format raw requires exactly one target node (got {len})"))?; + let [(_, result)] = array; + match result { + Ok(v) => { + println!("{}", value_to_string(&v)); + Ok(()) + } + Err(e) => bail!("{e}"), + } +} + +/// Strip surrounding quotes when the result is already a JSON string scalar. +fn value_to_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + _ => v.to_string(), + } +} + +fn print_table(outputs: &[(NodeName, Result)]) { + let node_w = outputs + .iter() + .map(|(n, _)| n.len()) + .max() + .unwrap_or(4) + .max(4); + println!("{: value_to_string(v), + Err(e) => format!("ERROR: {e}"), + }; + println!("{node:, + method: &str, + params: Value, + timeout: Duration, + retries: u32, +) -> Result)>> { + let method = method.to_string(); + let shared_client = Client::new(); + let mut outputs = util::in_parallel_tuples(&node_urls, move |name, url| { + let method = method.clone(); + let params = params.clone(); + let client = crate::rpc::RpcClient::with_client(shared_client.clone(), url, timeout); + async move { + let result = client + .rpc_request::(&method, params, retries) + .await + .map_err(|e| e.to_string()); + (name, result) + } + }) + .await; + outputs.sort_by(|(a, _), (b, _)| a.cmp(b)); + Ok(outputs) +} + +/// Fan out a REST call to the given CL nodes in parallel. +/// +/// `path` may omit the leading `/` (it is normalized internally). `body` is +/// sent verbatim as the HTTP body when supplied. Results are sorted by node +/// name; each entry parses the response as JSON when possible, falling back +/// to a JSON string of the raw body. Callers typically obtain `node_urls` +/// via `NodesMetadata::resolve_cl_targets`. +pub(crate) async fn fanout_cl( + node_urls: Vec<(NodeName, Url)>, + path: &str, + http_method: &Method, + body: Option<&str>, + timeout: Duration, + retries: u32, +) -> Result)>> { + let normalized_path = normalize_cl_path(path); + let body = body.map(str::to_string); + let http_method = http_method.clone(); + let client = Client::builder() + .timeout(timeout) + .build() + .wrap_err("Failed to build HTTP client")?; + let mut outputs = util::in_parallel_tuples(&node_urls, move |name, base| { + let path = normalized_path.clone(); + let body = body.clone(); + let http_method = http_method.clone(); + let client = client.clone(); + async move { + let result = call_cl_one(&client, base, &http_method, &path, body, retries).await; + (name, result) + } + }) + .await; + outputs.sort_by(|(a, _), (b, _)| a.cmp(b)); + Ok(outputs) +} + +/// CL endpoint catalog fetched from a single node's `GET /` index. +pub(crate) struct ClCatalog { + pub node: NodeName, + pub url: Url, + /// Parsed JSON catalog, or an error message if the fetch/parse failed. + pub endpoints: std::result::Result, +} + +/// Fetch the CL endpoint catalog from the given consensus-enabled node. The +/// catalog is a function of each node's build/config, so on a healthy network +/// every consensus-enabled node returns the same response; picking a specific +/// node would only matter during version skew. Callers therefore typically +/// pass the first consensus-enabled node from the manifest. +pub(crate) async fn fetch_cl_catalog( + node: NodeName, + base_url: Url, + timeout: Duration, +) -> Result { + let client = Client::builder() + .timeout(timeout) + .build() + .wrap_err("Failed to build HTTP client")?; + + // The catalog is served at `/` on the CL REST API. In remote mode the SSM + // proxy routes `//cl(/)?` to the upstream and, when path is + // empty, passes the original URI through unchanged. Forcing a trailing + // slash makes the proxy rewrite to `GET /` so the upstream returns the + // catalog instead of a 404. + let catalog_url = format!("{}/", base_url.as_str().trim_end_matches('/')); + + let endpoints = match client.get(&catalog_url).send().await { + Ok(resp) => { + let status = resp.status(); + match resp.text().await { + Ok(text) if status.is_success() => Ok(serde_json::from_str::(&text) + .unwrap_or_else(|_| Value::String(text.clone()))), + Ok(text) => { + let body_preview = text.trim(); + if body_preview.is_empty() { + Err(format!("HTTP {status}")) + } else { + Err(format!("HTTP {status}: {body_preview}")) + } + } + Err(e) => Err(format!("response read failed: {e}")), + } + } + Err(e) => Err(format!("request failed: {e}")), + }; + + Ok(ClCatalog { + node, + url: base_url, + endpoints, + }) +} + +// ---------- internal: per-node HTTP calls ----------------------------------- + +async fn call_cl_one( + client: &Client, + base: Url, + method: &Method, + path: &str, + body: Option, + retries: u32, +) -> std::result::Result { + let full_url = format!("{}{path}", base.as_str().trim_end_matches('/')); + let mut last_err: Option = None; + // `0..=retries` runs at least once (when retries == 0), so by exit + // `last_err` is always `Some` for the failing path. + for _ in 0..=retries { + let mut req = client.request(method.clone(), &full_url); + if let Some(b) = &body { + req = req.header(CONTENT_TYPE, "application/json").body(b.clone()); + } + match req.send().await { + Ok(resp) => { + let status = resp.status(); + let text = match resp.text().await { + Ok(t) => t, + Err(e) => { + last_err = Some(format!("response read failed: {e}")); + continue; + } + }; + let value = serde_json::from_str::(&text) + .unwrap_or_else(|_| Value::String(text.clone())); + if status.is_success() { + return Ok(value); + } + return Err(format!("HTTP {status}: {value}")); + } + Err(e) => last_err = Some(format!("request failed: {e}")), + } + } + Err(last_err.expect("0..=retries iterates at least once")) +} + +/// Accepts both `/consensus-state` and `consensus-state`. +fn normalize_cl_path(raw: &str) -> String { + if raw.starts_with('/') { + raw.to_string() + } else { + format!("/{raw}") + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_el_args(positional: Vec, raw: Option) -> ElArgs { + ElArgs { + method: String::new(), + positional, + raw, + common: CommonArgs { + timeout: Duration::ZERO, + retries: 0, + format: OutputFormat::Json, + }, + } + } + + #[test] + fn parse_positionals_zero_args_defaults_to_all_nodes() { + let args = make_el_args(vec![], None); + let (target, params) = args.parse_positionals().unwrap(); + assert!(target.is_none()); + assert!(params.is_empty()); + } + + #[test] + fn parse_positionals_one_arg_is_target() { + let args = make_el_args(vec!["validator1".to_string()], None); + let (target, params) = args.parse_positionals().unwrap(); + assert_eq!(target.as_deref(), Some("validator1")); + assert!(params.is_empty()); + } + + #[test] + fn parse_positionals_multi_arg_splits_target_and_params() { + let args = make_el_args( + vec![ + "validator1".to_string(), + "0xabc".to_string(), + "latest".to_string(), + ], + None, + ); + let (target, params) = args.parse_positionals().unwrap(); + assert_eq!(target.as_deref(), Some("validator1")); + assert_eq!(params, vec!["0xabc".to_string(), "latest".to_string()]); + } + + #[test] + fn parse_positionals_rejects_raw_plus_positional_params() { + let args = make_el_args( + vec!["validator1".to_string(), "0xabc".to_string()], + Some("[]".to_string()), + ); + let err = args.parse_positionals().unwrap_err(); + assert!(err.to_string().contains("--raw")); + } + + #[test] + fn parse_positionals_allows_raw_with_target_only() { + let args = make_el_args(vec!["validator1".to_string()], Some("[]".to_string())); + let (target, params) = args.parse_positionals().unwrap(); + assert_eq!(target.as_deref(), Some("validator1")); + assert!(params.is_empty()); + } + + #[test] + fn value_to_string_strips_quotes_only_for_string_scalars() { + assert_eq!(value_to_string(&json!("latest")), "latest"); + assert_eq!(value_to_string(&json!(42)), "42"); + assert_eq!(value_to_string(&json!(true)), "true"); + assert_eq!(value_to_string(&Value::Null), "null"); + assert_eq!(value_to_string(&json!([1, 2])), "[1,2]"); + assert_eq!(value_to_string(&json!({"k": "v"})), "{\"k\":\"v\"}"); + } + + #[test] + fn node_output_json_emits_result_or_error_exclusively() { + let ok = node_output_json("validator1", &Ok(json!(42))); + assert_eq!(ok, json!({"node": "validator1", "result": 42})); + assert!(ok.get("error").is_none()); + + let err = node_output_json("validator2", &Err("boom".to_string())); + assert_eq!(err, json!({"node": "validator2", "error": "boom"})); + assert!(err.get("result").is_none()); + } + + #[test] + fn normalize_cl_path_prepends_slash_when_missing() { + assert_eq!(normalize_cl_path("consensus-state"), "/consensus-state"); + assert_eq!(normalize_cl_path("commit?height=42"), "/commit?height=42"); + } + + #[test] + fn normalize_cl_path_preserves_existing_slash() { + assert_eq!(normalize_cl_path("/consensus-state"), "/consensus-state"); + assert_eq!(normalize_cl_path("/"), "/"); + } +} diff --git a/crates/quake/src/run.rs b/crates/quake/src/run.rs new file mode 100644 index 00000000..2f28bd47 --- /dev/null +++ b/crates/quake/src/run.rs @@ -0,0 +1,2484 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::cmp::Ordering; +use std::collections::HashMap; +use std::fmt; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use arc_checks::{ + check_mempool, compute_health_deltas, fetch_all_metrics, parse_all_health_metrics, + parse_perf_metrics_delta, NodeHealthDelta, NodePerfData, +}; +use chrono::{DateTime, Utc}; +use clap::{Args, Subcommand}; +use color_eyre::eyre::{bail, eyre, Result, WrapErr}; +use serde::{Deserialize, Serialize}; +use tokio::join; +use tokio::time::{sleep, Instant}; +use tracing::{debug, info, warn}; +use url::Url; + +use crate::genesis; +use crate::testnet::Testnet; +use spammer::SpammerArgs; + +pub(crate) const DEFAULT_BLOCK_GAS_LIMIT: u64 = 30_000_000; + +/// Transaction mix applied to every phase (not configurable — varying load type +/// is out of scope for this experiment). +pub(crate) const TX_MIX: &str = "transfer=35,legacy=25,erc20=25,guzzler=15"; +pub(crate) const GUZZLER_FN_WEIGHTS: &str = "hash-loop=77@200,storage-write=3@1,storage-read=20@35"; +pub(crate) const ERC20_FN_WEIGHTS: &str = "transfer=100"; + +fn format_tx_type_mix(m: &spammer::TxTypeMix) -> String { + format!( + "transfer={},legacy={},erc20={},guzzler={}", + m.transfer, m.legacy, m.erc20, m.guzzler, + ) +} + +fn format_erc20_fn_weights(w: &spammer::Erc20FnWeights) -> String { + format!( + "transfer={},approve={},transfer-from={}", + w.transfer, w.approve, w.transfer_from, + ) +} + +fn format_guzzler_fn_weights(w: &spammer::GuzzlerFnWeights) -> String { + format!( + "hash-loop={}@{},storage-write={}@{},storage-read={}@{},guzzle={}@{},guzzle2={}@{}", + w.hash_loop.weight, + w.hash_loop.arg, + w.storage_write.weight, + w.storage_write.arg, + w.storage_read.weight, + w.storage_read.arg, + w.guzzle.weight, + w.guzzle.arg, + w.guzzle2.weight, + w.guzzle2.arg, + ) +} + +/// Prometheus metrics fetched at the end of the experiment for offline analysis. +/// These cover exactly the signals used for inline detection plus consensus latency. +// Prometheus metric names to download at experiment end. +// +// All CL metrics use prometheus-client 0.23, which exposes: +// - Histograms as _sum, _count (no queryable base name) +// - Counters as _total +// Querying the bare histogram base name (e.g. "arc_malachite_app_block_time") +// returns empty results because no series with that exact name exists. +pub(crate) const DOWNLOAD_METRICS: &[&str] = &[ + // Histograms — download both _sum and _count to allow average computation + "arc_malachite_app_block_time_sum", + "arc_malachite_app_block_time_count", + "arc_malachite_app_block_build_time_sum", + "arc_malachite_app_block_build_time_count", + "arc_malachite_app_block_finalize_time_sum", + "arc_malachite_app_block_finalize_time_count", + "arc_malachite_app_block_gas_used_sum", + "arc_malachite_app_block_transactions_count_sum", + "malachitebft_core_consensus_consensus_round_sum", + "malachitebft_core_consensus_consensus_time_sum", + // Counters + "arc_malachite_app_height_restart_count_total", + "arc_malachite_app_sync_fell_behind_count_total", + // EL process CPU — counter (seconds), compute rate() for utilisation % + "reth_process_cpu_seconds_total", + // EL txpool — gauges; pending = executable now, queued = waiting on a nonce gap + "reth_transaction_pool_pending_pool_transactions", + "reth_transaction_pool_queued_pool_transactions", + // Pool evictions — pending is the sub-pool that fills first and whose cascade + // clears the queued pool; basefee/blob counters included for completeness. + "reth_transaction_pool_pending_transactions_evicted_total", + "reth_transaction_pool_basefee_transactions_evicted_total", + "reth_transaction_pool_blob_transactions_evicted_total", + "reth_transaction_pool_queued_transactions_evicted_total", + // EL process memory — gauge (resident set size in bytes) + "reth_process_resident_memory_bytes", +]; + +// ── Saturation detection thresholds ────────────────────────────────────────── + +/// Gas/s or actual TPS must grow by at least this fraction of the previous value +/// to avoid the plateau signal. +const PLATEAU_THRESHOLD: f64 = 0.10; + +/// The TPS ratio (actual/offered) must not drop by more than this fraction vs +/// the prior phase. +const TPS_RATIO_DROP_THRESHOLD: f64 = 0.15; + +/// p95 latency must not more than double vs the prior phase. +const LATENCY_SPIKE_FACTOR: f64 = 2.0; + +/// Mempool must grow by at least this many pending transactions to signal. +const MEMPOOL_GROWTH_MIN: f64 = 100.0; + +// ── CLI ─────────────────────────────────────────────────────────────────────── + +#[derive(Subcommand)] +pub(crate) enum RunSubcommand { + /// Ramp offered TPS against an already-running testnet, measure throughput + /// and latency per phase, and identify the saturation point. + /// + /// Iterates through the configured rate phases with cooldowns, downloads + /// a targeted Prometheus metrics snapshot, and writes experiment.json. + /// The testnet must be started separately and is left running afterwards. + /// + /// Examples: + /// quake -f scenarios/mainnet.toml run saturation + /// quake -f scenarios/mainnet.toml run saturation --rates 100,500,1000,2000 -d 5m + #[command(verbatim_doc_comment)] + Saturation { + #[command(flatten)] + args: SaturationArgs, + }, +} + +#[derive(Args)] +pub(crate) struct SaturationArgs { + /// Offered TPS targets in strictly ascending order. + /// + /// Comma-separated values, `START-END:STEP` ranges, or a mix of both. + /// Range expansion is inclusive on both ends when divisible; an uneven + /// endpoint stops at the largest `start + k*step <= end`. + /// + /// Examples: + /// `500,1000,2000,4000` (the default) + /// `1000-2000:100` (1000, 1100, …, 2000) + /// `500,1000-2000:100,4000` (singles + range mixed) + #[clap(long, default_value = "500,1000,2000,4000")] + pub rates: String, + + /// Duration to hold each rate phase + #[clap(short = 'd', long, default_value = "5m", value_parser = crate::parse_duration)] + pub phase_duration: Duration, + + /// Ramp-up period: runs actual load at the first rate to warm generator nonces + /// and the txpool before any measured phase begins + #[clap(long, default_value = "90s", value_parser = crate::parse_duration)] + pub rampup: Duration, + + /// Cooldown between phases to let mempool and metrics settle + #[clap(long, default_value = "90s", value_parser = crate::parse_duration)] + pub cooldown: Duration, + + /// Number of parallel spammer generators + #[clap(long, default_value_t = 10)] + pub generators: usize, + + /// Wall-clock hard limit for the entire experiment + #[clap(long, default_value = "3h", value_parser = crate::parse_duration)] + pub max_duration: Duration, + + /// Directory for experiment artifacts (experiment.json, latency CSVs, metrics tarball) + #[clap(long, default_value = ".quake/experiments")] + pub output_dir: PathBuf, + + /// Comma-separated list of target nodes for the spammer (exact names or + /// manifest groups like `ALL_VALIDATORS`). When omitted, every manifest + /// node is targeted. + #[clap(long, value_delimiter = ',')] + pub targets: Option>, + + /// Weighted transaction type mix (same format as `quake load --mix`, + /// e.g. `transfer=70,erc20=30`). Defaults to the saturation profile + /// `transfer=35,legacy=25,erc20=25,guzzler=15`. + #[clap(long = "mix")] + pub tx_type_mix: Option, + + /// Weighted function mix for guzzler calls (same format as + /// `quake load --guzzler-fn-weights`). Defaults to the saturation profile + /// `hash-loop=77@200,storage-write=3@1,storage-read=20@35`. + #[clap(long = "guzzler-fn-weights")] + pub guzzler_fn_weights: Option, + + /// Weighted function mix for ERC-20 calls (same format as + /// `quake load --erc20-fn-weights`). Defaults to 100% `transfer`. + #[clap(long = "erc20-fn-weights")] + pub erc20_fn_weights: Option, + + /// Extra random bytes appended to each transaction's input field + /// (same as `quake load --tx-input-size`). + #[clap(long, default_value_t = 0)] + pub tx_input_size: usize, +} + +// ── experiment.json schema ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ExperimentMetadata { + /// Unique identifier: "saturation-YYYYMMDDTHHMMSSZ" + pub experiment_id: String, + pub parameters: SaturationParameters, + pub status: ExperimentStatus, + pub started_at: DateTime, + pub ended_at: Option>, + pub phases: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SaturationParameters { + pub rates: Vec, + pub hold_secs: u64, + pub rampup_secs: u64, + pub cooldown_secs: u64, + pub generators: usize, + pub tx_mix: String, + pub guzzler_fn_weights: String, + pub erc20_fn_weights: String, + /// The manifest used to run the experiment, parsed from its TOML file + /// into a JSON object. Embedded structurally so the report is + /// self-describing — every node, region, image tag, and config override + /// is captured without a separate schema. + pub manifest: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PhaseRecord { + pub offered_tps: u64, + pub started_at: DateTime, + /// Timestamp when the spammer finished (load phase ended, cooldown not included) + pub load_ended_at: DateTime, + /// Timestamp when the cooldown ended and the next phase (or cleanup) began + pub ended_at: DateTime, + pub metrics: PhaseMetrics, + /// Saturation signals that fired during this phase transition + pub signals: Vec, +} + +/// All measured values for a single rate phase. +/// +/// Prometheus-derived fields are populated from before/after scrape deltas. +/// `max_mempool` is queried via RPC at the end of the load window. +/// Latency fields come from the per-phase CSV written by the spammer. +/// CPU fields are queried from the Prometheus API at phase end. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(crate) struct PhaseMetrics { + /// Gas committed per second across the phase window (`block_gas_used.sum / duration_s`). + pub gas_per_sec: Option, + /// Transactions committed per second (`block_tx_count.sum / duration_s`). + pub actual_tps: Option, + /// Average TPS as observed locally by the spammer: total transactions + /// submitted divided by the spammer's wall-clock run. Independent of + /// server acceptance and of the chain-confirmed rate. Drift from + /// `offered_tps` (configured target) indicates the load generator itself + /// could not keep up with the requested rate. + pub actual_offered_tps: Option, + /// Average bytes-per-second locally offered by the spammer (total tx + /// bytes / spammer wall-clock). Together with `actual_offered_tps` this + /// shows whether load is dominated by many tiny txs or fewer fat ones. + pub actual_offered_bytes_per_sec: Option, + /// Average block gas utilization as % of the block gas limit + /// (`avg_gas_per_block / block_gas_limit × 100`). Near 100% = blocks full. + pub fill_pct: Option, + /// Average time between consecutive blocks in seconds (`block_time.avg`). + pub avg_block_time_s: Option, + /// Average time to build a block in milliseconds (`block_build_time.avg × 1000`). + /// Measured from the CL's GetValue call until the payload is returned to consensus. + pub avg_block_build_time_ms: Option, + /// Average time to finalize a block in milliseconds (`block_finalize_time.avg × 1000`). + /// Covers `engine_newPayload` + `engine_forkchoiceUpdated` on all nodes. + pub avg_block_finalize_time_ms: Option, + /// Fraction of consensus decisions reached in round 0 (informational, not a signal). + pub round_0_pct: Option, + /// Fraction of consensus decisions reached in round 1 (informational, not a signal). + pub round_1_pct: Option, + /// Peak pending sub-pool transaction count across all nodes during the + /// load window. Sourced from Prometheus + /// `max(max_over_time(reth_transaction_pool_pending_pool_transactions[Ns]))` + /// rather than the geth-compat `txpool_status` RPC, which silently + /// reports `0` for any node whose RPC handler stalls under load — i.e. + /// the very node we want to observe. + pub max_mempool: Option, + /// Average pending sub-pool transaction count across all nodes during + /// the load window (`avg(avg_over_time(..._pending_pool_transactions))`). + pub avg_pending_mempool: Option, + /// Peak queued (nonce-gapped) sub-pool transaction count across all + /// nodes during the load window + /// (`max(max_over_time(..._queued_pool_transactions))`). + pub max_queued_mempool: Option, + /// Average queued (nonce-gapped) sub-pool transaction count across all + /// nodes during the load window. + pub avg_queued_mempool: Option, + /// Peak basefee sub-pool depth across all nodes during the load window (via + /// Prometheus: `max(max_over_time(reth_transaction_pool_basefee_pool_transactions[Ns]))`). + /// Not exposed by `txpool_status` RPC — geth-compat returns only pending+queued, so + /// we'd be blind to basefee overflow without this. Legacy txs whose gas price + /// falls below the chain's current base fee land here. + pub max_basefee_mempool: Option, + /// Average basefee sub-pool depth across all nodes during the load window (via + /// Prometheus: `avg(avg_over_time(reth_transaction_pool_basefee_pool_transactions[Ns]))`). + pub avg_basefee_mempool: Option, + /// Peak blob sub-pool depth across all nodes during the load window. Recorded for + /// completeness; ~always 0 in our experiments (no EIP-4844 traffic). + pub max_blob_mempool: Option, + /// Average blob sub-pool depth across all nodes during the load window. + pub avg_blob_mempool: Option, + /// Peak pending sub-pool cumulative size in megabytes during the load window + /// (from `reth_transaction_pool_pending_pool_size_bytes`). Pairs with + /// `max_mempool` (count) — Reth caps each subpool on count AND size in MB + /// independently, so both dimensions matter for diagnosing "txpool is full". + pub max_pending_size_mb: Option, + /// Average pending sub-pool cumulative size in megabytes during the load window. + pub avg_pending_size_mb: Option, + /// Peak basefee sub-pool cumulative size in megabytes during the load window. + pub max_basefee_size_mb: Option, + /// Average basefee sub-pool cumulative size in megabytes during the load window. + pub avg_basefee_size_mb: Option, + /// Peak queued sub-pool cumulative size in megabytes during the load window. + pub max_queued_size_mb: Option, + /// Average queued sub-pool cumulative size in megabytes during the load window. + pub avg_queued_size_mb: Option, + /// Peak blob sub-pool cumulative size in megabytes during the load window. + pub max_blob_size_mb: Option, + /// Average blob sub-pool cumulative size in megabytes during the load window. + pub avg_blob_size_mb: Option, + /// Total transactions evicted from any sub-pool during the phase, summed across + /// all nodes (`increase({pending|basefee|blob|queued}_evicted_total[Ns])`). + /// In practice only the pending counter fires; queued is cleared via cascade. + pub pool_evictions: Option, + /// Mean submit-to-finalized latency in milliseconds, from the spammer CSV. + pub latency_avg_ms: Option, + /// Sample standard deviation of submit-to-finalized latency in milliseconds. + /// Together with the mean this captures spread without the percentile sorting cost. + pub latency_stddev_ms: Option, + /// Median submit-to-finalized latency in milliseconds, from the spammer CSV. + pub latency_p50_ms: Option, + /// 95th-percentile submit-to-finalized latency in milliseconds, from the spammer CSV. + pub latency_p95_ms: Option, + /// Average EL (Reth) CPU utilization across validators during the load window + /// (% of one core; 100 = one full core, 200 = two cores). + pub el_cpu_avg_pct: Option, + /// Maximum EL CPU utilization seen on any single validator during the load window. + pub el_cpu_max_pct: Option, + /// Average CL (Malachite) CPU utilization across validators during the load window + /// (same units as `el_cpu_avg_pct`). + pub cl_cpu_avg_pct: Option, + /// Maximum CL CPU utilization seen on any single validator during the load window. + pub cl_cpu_max_pct: Option, + /// Average EL (Reth) resident memory in MiB across validators at phase end + /// (`avg(reth_process_resident_memory_bytes) / 1 048 576`). + pub el_mem_avg_mb: Option, + /// Peak EL (Reth) resident memory in MiB on any single validator at phase end. + pub el_mem_peak_mb: Option, + /// JSON-RPC error counts from the spammer, keyed by the raw server error string. + /// Empty when fire-and-forget mode produced no rejected transactions. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub rpc_errors: HashMap, +} + +/// A saturation signal that fired comparing this phase to the previous one. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SaturationSignal { + /// Gas/s grew by less than the plateau threshold vs the prior phase + GasPlateaued, + /// Actual committed TPS grew by less than the plateau threshold + TpsPlateaued, + /// Ratio of actual TPS to offered TPS dropped significantly + TpsRatioDrop, + /// Latency p95 more than doubled vs the prior phase + LatencySpike, + /// Max mempool depth grew between phases + MempoolGrowth, + /// Any single EL node exceeded one full CPU core during the phase + ElCpuSaturated, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "type")] +pub(crate) enum ExperimentStatus { + Completed, + TimedOut, + Failed { reason: String }, +} + +// ── Measurement helpers ─────────────────────────────────────────────────────── + +/// Aggregate per-node Prometheus deltas into single phase-level metrics. +/// +/// Since all validators commit the same blocks, values should be nearly identical +/// across nodes; averaging reduces measurement noise. +fn aggregate_phase_metrics( + perf_deltas: &[NodePerfData], + health_deltas: &[NodeHealthDelta], + block_gas_limit: u64, + phase_duration_secs: f64, +) -> PhaseMetrics { + let gas_per_sec = avg_opt(perf_deltas.iter().filter_map(|n| { + n.block_gas_used + .as_ref() + .map(|h| h.sum / phase_duration_secs) + })); + + let actual_tps = avg_opt(perf_deltas.iter().filter_map(|n| { + n.block_tx_count + .as_ref() + .map(|h| h.sum / phase_duration_secs) + })); + + let fill_pct = avg_opt(perf_deltas.iter().filter_map(|n| { + n.block_gas_used.as_ref().and_then(|h| { + if h.count > 0 { + Some(h.sum / h.count as f64 / block_gas_limit as f64 * 100.0) + } else { + None + } + }) + })); + + let avg_block_time_s = avg_opt( + perf_deltas + .iter() + .filter_map(|n| n.block_time.as_ref().map(|h| h.avg)), + ); + + let avg_block_build_time_ms = avg_opt( + perf_deltas + .iter() + .filter_map(|n| n.block_build_time.as_ref().map(|h| h.avg * 1000.0)), + ); + + let avg_block_finalize_time_ms = avg_opt( + perf_deltas + .iter() + .filter_map(|n| n.block_finalize_time.as_ref().map(|h| h.avg * 1000.0)), + ); + + // Sum round deltas across all nodes for a network-wide view. + let total_decisions: i64 = health_deltas.iter().map(|n| n.delta_decisions).sum(); + let total_round_0: i64 = health_deltas.iter().map(|n| n.delta_round_0).sum(); + let total_round_1: i64 = health_deltas.iter().map(|n| n.delta_round_1).sum(); + + let (round_0_pct, round_1_pct) = if total_decisions > 0 { + let d: f64 = total_decisions as f64; + ( + Some(total_round_0 as f64 / d * 100.0), + Some(total_round_1 as f64 / d * 100.0), + ) + } else { + (None, None) + }; + + PhaseMetrics { + gas_per_sec, + actual_tps, + fill_pct, + avg_block_time_s, + avg_block_build_time_ms, + avg_block_finalize_time_ms, + round_0_pct, + round_1_pct, + ..Default::default() + } +} + +fn avg_opt(values: impl Iterator) -> Option { + let (sum, count) = values.fold((0.0_f64, 0usize), |(s, c), v| (s + v, c + 1)); + if count == 0 { + None + } else { + Some(sum / count as f64) + } +} + +/// Parse a latency CSV written by the spammer and return per-transaction +/// latencies in milliseconds. +/// +/// CSV format (columns): tx_hash, submitted_at, finalized_observed_at, … +/// Both timestamp columns are RFC 3339 with millisecond precision. +fn read_latency_csv(path: &Path) -> Result> { + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut latencies = Vec::new(); + + for (i, line) in reader.lines().enumerate() { + let line = line?; + if i == 0 { + continue; // skip header + } + let mut cols = line.splitn(3, ','); + let _tx_hash = cols.next(); + let submitted = cols.next().unwrap_or("").trim(); + let finalized = cols + .next() + .and_then(|s| s.split(',').next()) + .unwrap_or("") + .trim(); + + let t_sub = DateTime::parse_from_rfc3339(submitted); + let t_fin = DateTime::parse_from_rfc3339(finalized); + + if let (Ok(s), Ok(f)) = (t_sub, t_fin) { + let diff_ms = (f - s).num_milliseconds(); + if diff_ms >= 0 { + latencies.push(diff_ms as f64); + } + } + } + Ok(latencies) +} + +/// Compute p50 and p95 latency percentiles in milliseconds. +/// Mean, sample standard deviation, p50, and p95 of a latency sample. +/// +/// Returns `(avg, stddev, p50, p95)`. Each is `None` only when the input is +/// empty (stddev is also `None` for a single-element sample, since sample +/// stddev requires n ≥ 2). +fn compute_latency_stats( + latencies_ms: &[f64], +) -> (Option, Option, Option, Option) { + if latencies_ms.is_empty() { + return (None, None, None, None); + } + let n = latencies_ms.len(); + let sum: f64 = latencies_ms.iter().sum(); + let mean = sum / n as f64; + let stddev = if n >= 2 { + let var = latencies_ms.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1) as f64; + Some(var.sqrt()) + } else { + None + }; + let mut sorted = latencies_ms.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + ( + Some(mean), + stddev, + Some(sorted_percentile(&sorted, 50.0)), + Some(sorted_percentile(&sorted, 95.0)), + ) +} + +fn sorted_percentile(sorted: &[f64], pct: f64) -> f64 { + let n = sorted.len(); + if n == 1 { + return sorted[0]; + } + let idx = ((pct / 100.0) * (n - 1) as f64).round() as usize; + sorted[idx.min(n - 1)] +} + +// ── Saturation signal detection ─────────────────────────────────────────────── + +/// Any single EL node exceeding this CPU threshold (% of one core) is a saturation signal. +/// The payload builder is single-threaded, so its ceiling is exactly one core (100%) +/// regardless of how many vCPUs the machine has. Once any node's Reth process crosses this +/// threshold the builder thread is saturated and TPS will not grow further. +const EL_CPU_SATURATION_PCT: f64 = 100.0; + +/// Detect which saturation signals fired by comparing `current` to `prev`. +/// +/// Returns an empty `Vec` for the first phase (no previous to compare against). +fn detect_saturation_signals( + prev: Option<&PhaseRecord>, + current: &PhaseRecord, +) -> Vec { + let mut signals = Vec::new(); + let Some(prev) = prev else { return signals }; + if prev.offered_tps == 0 || current.offered_tps == 0 { + return signals; + } + + // GasPlateaued: gas throughput barely grew despite higher offered load + if let (Some(g_prev), Some(g_curr)) = (prev.metrics.gas_per_sec, current.metrics.gas_per_sec) { + if g_prev > 0.0 && (g_curr - g_prev) / g_prev < PLATEAU_THRESHOLD { + signals.push(SaturationSignal::GasPlateaued); + } + } + + // TpsPlateaued: committed TPS barely grew + if let (Some(t_prev), Some(t_curr)) = (prev.metrics.actual_tps, current.metrics.actual_tps) { + if t_prev > 0.0 && (t_curr - t_prev) / t_prev < PLATEAU_THRESHOLD { + signals.push(SaturationSignal::TpsPlateaued); + } + } + + // TpsRatioDrop: fraction of offered load that was committed dropped significantly + if let (Some(t_prev), Some(t_curr)) = (prev.metrics.actual_tps, current.metrics.actual_tps) { + let ratio_prev = t_prev / prev.offered_tps as f64; + let ratio_curr = t_curr / current.offered_tps as f64; + if ratio_prev > 0.0 && (ratio_prev - ratio_curr) / ratio_prev > TPS_RATIO_DROP_THRESHOLD { + signals.push(SaturationSignal::TpsRatioDrop); + } + } + + // LatencySpike: p95 more than doubled + if let (Some(p95_prev), Some(p95_curr)) = + (prev.metrics.latency_p95_ms, current.metrics.latency_p95_ms) + { + if p95_prev > 0.0 && p95_curr > p95_prev * LATENCY_SPIKE_FACTOR { + signals.push(SaturationSignal::LatencySpike); + } + } + + // MempoolGrowth: pending transactions accumulated across the phase boundary + if let (Some(m_prev), Some(m_curr)) = (prev.metrics.max_mempool, current.metrics.max_mempool) { + if m_curr > m_prev + MEMPOOL_GROWTH_MIN { + signals.push(SaturationSignal::MempoolGrowth); + } + } + + // ElCpuSaturated: the busiest EL node exceeded one full CPU core + if let Some(cpu_max) = current.metrics.el_cpu_max_pct { + if cpu_max > EL_CPU_SATURATION_PCT { + signals.push(SaturationSignal::ElCpuSaturated); + } + } + + signals +} + +// ── Inline phase table ──────────────────────────────────────────────────────── + +/// Reprint the complete phase-results table at every phase boundary. +/// +/// Cumulative re-emission means a reader scrolling back through interleaved +/// `INFO`-level log lines always finds a contiguous, fully-rendered table at +/// the bottom — instead of one-row-at-a-time fragments scattered between log +/// messages, which makes columnar comparison difficult. +fn print_phase_table(phases: &[PhaseRecord]) { + println!(); + println!( + "{:>6} {:>7} {:>8} {:>8} {:>7} {:>6} {:>8} {:>11} {:>13} {:>13} {:>13} {:>13} {:>8} {:>9} {:>9} {:>9} {:>9} {:>8} {:>8}", + "Rate", "OffTPS", "ActlTPS", "Gas/s", "Fill%", "BlkT", "FinalMs", + "Avg±SDms", "PeakPend(MB)", "AvgPend(MB)", "PeakQued(MB)", "AvgQued(MB)", + "PoolEvct", "ELCPUavg", "ELCPUmax", "CLCPUavg", "CLCPUmax", "MemAvgMB", "MemPkMB", + ); + println!("{}", "-".repeat(196)); + for phase in phases { + println!("{phase}"); + } + println!(); +} + +/// Render a "count(MB)" cell. Either piece is rendered as `-` when missing, +/// so a half-populated row stays legible. Used for the mempool columns where +/// Reth caps each subpool on count AND size independently. +fn fmt_pool_cell(count: Option, size_mb: Option) -> String { + match (count, size_mb) { + (Some(c), Some(s)) => format!("{c:.0}({s:.1})"), + (Some(c), None) => format!("{c:.0}(-)"), + (None, Some(s)) => format!("-({s:.1})"), + (None, None) => "-".into(), + } +} + +impl fmt::Display for PhaseRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let m = &self.metrics; + let off_tps = m + .actual_offered_tps + .map(|t| format!("{t:.0}")) + .unwrap_or_else(|| "-".into()); + let tps = m + .actual_tps + .map(|t| format!("{t:.0}")) + .unwrap_or_else(|| "-".into()); + let gas = m.gas_per_sec.map(fmt_gas).unwrap_or_else(|| "-".into()); + let fill = m + .fill_pct + .map(|f| format!("{f:.1}%")) + .unwrap_or_else(|| "-".into()); + let blkt = m + .avg_block_time_s + .map(|t| format!("{t:.2}s")) + .unwrap_or_else(|| "-".into()); + let final_ms = m + .avg_block_finalize_time_ms + .map(|t| format!("{t:.0}")) + .unwrap_or_else(|| "-".into()); + let avg_sd = match (m.latency_avg_ms, m.latency_stddev_ms) { + (Some(avg), Some(sd)) => format!("{avg:.0}±{sd:.0}"), + (Some(avg), None) => format!("{avg:.0}"), + _ => "-".into(), + }; + let pool = fmt_pool_cell(m.max_mempool, m.max_pending_size_mb); + let avg_pend = fmt_pool_cell(m.avg_pending_mempool, m.avg_pending_size_mb); + let queued = fmt_pool_cell(m.max_queued_mempool, m.max_queued_size_mb); + let avg_qued = fmt_pool_cell(m.avg_queued_mempool, m.avg_queued_size_mb); + let pool_evct = m + .pool_evictions + .map(|e| format!("{e:.0}")) + .unwrap_or_else(|| "-".into()); + let el_cpu_avg = m + .el_cpu_avg_pct + .map(|c| format!("{c:.0}%")) + .unwrap_or_else(|| "-".into()); + let el_cpu_max = m + .el_cpu_max_pct + .map(|c| format!("{c:.0}%")) + .unwrap_or_else(|| "-".into()); + let cl_cpu_avg = m + .cl_cpu_avg_pct + .map(|c| format!("{c:.0}%")) + .unwrap_or_else(|| "-".into()); + let cl_cpu_max = m + .cl_cpu_max_pct + .map(|c| format!("{c:.0}%")) + .unwrap_or_else(|| "-".into()); + let mem_avg = m + .el_mem_avg_mb + .map(|v| format!("{v:.0}")) + .unwrap_or_else(|| "-".into()); + let mem_pk = m + .el_mem_peak_mb + .map(|v| format!("{v:.0}")) + .unwrap_or_else(|| "-".into()); + write!( + f, + "{:>6} {:>7} {:>8} {:>8} {:>7} {:>6} {:>8} {:>11} {:>13} {:>13} {:>13} {:>13} {:>8} {:>9} {:>9} {:>9} {:>9} {:>8} {:>8}", + self.offered_tps, off_tps, tps, gas, fill, blkt, final_ms, + avg_sd, pool, avg_pend, queued, avg_qued, pool_evct, + el_cpu_avg, el_cpu_max, cl_cpu_avg, cl_cpu_max, + mem_avg, mem_pk, + ) + } +} + +fn fmt_gas(gas_per_sec: f64) -> String { + if gas_per_sec >= 1_000_000.0 { + format!("{:.1}M", gas_per_sec / 1_000_000.0) + } else if gas_per_sec >= 1_000.0 { + format!("{:.1}K", gas_per_sec / 1_000.0) + } else { + format!("{gas_per_sec:.0}") + } +} + +#[cfg(test)] +fn signal_abbrev(s: &SaturationSignal) -> &'static str { + match s { + SaturationSignal::GasPlateaued => "GAS_PLATEAU", + SaturationSignal::TpsPlateaued => "TPS_PLATEAU", + SaturationSignal::TpsRatioDrop => "TPS_RATIO", + SaturationSignal::LatencySpike => "LATENCY", + SaturationSignal::MempoolGrowth => "MEMPOOL", + SaturationSignal::ElCpuSaturated => "CPU_SAT", + } +} + +// ── Prometheus helpers ──────────────────────────────────────────────────────── + +/// Query the Prometheus instant API for a scalar PromQL expression. +/// +/// `eval_time` is a Unix timestamp; the query is evaluated at that point in time. +/// Returns `None` on any network or parse error. +async fn query_prometheus_scalar(prometheus_url: &str, query: &str, eval_time: i64) -> Option { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .ok()?; + let url = format!("{prometheus_url}/api/v1/query"); + let resp = client + .get(&url) + .query(&[("query", query), ("time", &eval_time.to_string())]) + .send() + .await + .ok()?; + let body: serde_json::Value = resp.json().await.ok()?; + body.pointer("/data/result/0/value/1") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) +} + +/// Query average and max EL CPU utilization (% of one core) over the phase window. +/// +/// Uses `rate(reth_process_cpu_seconds_total[{duration}s])` evaluated at phase end. +/// Returns `(None, None)` if Prometheus is unreachable or the metric is absent. +async fn query_el_cpu_pct( + prometheus_url: &str, + phase_duration_secs: f64, + eval_time: i64, +) -> (Option, Option) { + let dur = phase_duration_secs as u64; + let q_avg = format!("avg(rate(reth_process_cpu_seconds_total[{dur}s])) * 100"); + let q_max = format!("max(rate(reth_process_cpu_seconds_total[{dur}s])) * 100"); + let (avg, max) = join!( + query_prometheus_scalar(prometheus_url, &q_avg, eval_time), + query_prometheus_scalar(prometheus_url, &q_max, eval_time), + ); + (avg, max) +} + +/// Query average and peak CL (Malachite) CPU utilization across validators +/// over the load window, in % of one core. +/// +/// Uses `rate(process_cpu_seconds_total{job=~".+_cl"}[{duration}s])` — the +/// `_cl` job filter excludes the EL targets which expose +/// `reth_process_cpu_seconds_total` under a different name but could otherwise +/// pollute results if any other exporter ever surfaces a bare +/// `process_cpu_seconds_total`. +async fn query_cl_cpu_pct( + prometheus_url: &str, + phase_duration_secs: f64, + eval_time: i64, +) -> (Option, Option) { + let dur = phase_duration_secs as u64; + // malachite-app's prometheus exporter prefixes its process metrics with + // `arc_malachite_app_*`; plain `process_cpu_seconds_total` only exists on + // the cc node-exporter, so without the prefix this query returns no series. + let q_avg = format!( + r#"avg(rate(arc_malachite_app_process_cpu_seconds_total{{job=~".+_cl"}}[{dur}s])) * 100"# + ); + let q_max = format!( + r#"max(rate(arc_malachite_app_process_cpu_seconds_total{{job=~".+_cl"}}[{dur}s])) * 100"# + ); + let (avg, max) = join!( + query_prometheus_scalar(prometheus_url, &q_avg, eval_time), + query_prometheus_scalar(prometheus_url, &q_max, eval_time), + ); + (avg, max) +} + +/// Query average and peak EL resident memory in MiB at phase end. +/// +/// Uses `reth_process_resident_memory_bytes` evaluated as an instant vector. +/// Returns `(None, None)` if Prometheus is unreachable or the metric is absent. +async fn query_el_memory_mb(prometheus_url: &str, eval_time: i64) -> (Option, Option) { + let mib = 1_048_576_f64; + let q_avg = format!("avg(reth_process_resident_memory_bytes) / {mib}"); + let q_peak = format!("max(reth_process_resident_memory_bytes) / {mib}"); + let (avg, peak) = join!( + query_prometheus_scalar(prometheus_url, &q_avg, eval_time), + query_prometheus_scalar(prometheus_url, &q_peak, eval_time), + ); + (avg, peak) +} + +/// Query total transactions evicted from all sub-pools (pending, basefee, blob, queued) +/// over the phase window. In practice only the pending counter fires; the queued pool +/// is cleared via cascade from pending evictions and its own counter stays at zero. +async fn query_pool_evictions( + prometheus_url: &str, + phase_duration_secs: f64, + eval_time: i64, +) -> Option { + let dur = phase_duration_secs as u64; + let query = format!( + r#"sum(increase({{__name__=~"reth_transaction_pool_(pending|basefee|blob|queued)_transactions_evicted_total"}}[{dur}s]))"# + ); + query_prometheus_scalar(prometheus_url, &query, eval_time).await +} + +/// Query chain-confirmed TPS over the spammer's actual sending window. +/// +/// Uses the histogram `_sum` (per-block tx count summed across observations) +/// from the consensus app. `avg(rate(_sum[duration_s]))` evaluated at the +/// spammer's `finished_at` gives per-node mean tx-per-second over the window; +/// averaging across nodes mirrors `aggregate_phase_metrics`. Returns `None` +/// when Prometheus is unreachable or the metric is absent. +async fn query_chain_actual_tps( + prometheus_url: &str, + phase_duration_secs: f64, + eval_time: i64, +) -> Option { + let dur = phase_duration_secs as u64; + let query = format!("avg(rate(arc_malachite_app_block_transactions_count_sum[{dur}s]))"); + query_prometheus_scalar(prometheus_url, &query, eval_time).await +} + +/// Time-aligned per-block average for a histogram metric, restricted to the +/// spammer's actual sending window. Aggregates across nodes as +/// `sum(rate(_sum)) / sum(rate(_count))` — total observed time across the +/// whole cluster divided by total observations across the whole cluster. +/// +/// The earlier `avg(rate(_sum) / rate(_count))` form computed a per-node +/// average and then averaged across nodes, which produces `NaN` whenever +/// any node's `rate(_count)` is zero (e.g. `block_build_time` is only +/// recorded by the block proposer, so non-proposer nodes have a flat +/// counter during the window and trigger `0/0`). Sum-then-divide tolerates +/// sparse observers and is the form a reader expects for "per-block +/// average across the network". +/// +/// Returns `None` when Prometheus is unreachable, the metric is absent, or +/// no blocks landed in the window (total count delta = 0). +async fn query_block_histogram_avg( + prometheus_url: &str, + metric_base: &str, + phase_duration_secs: f64, + eval_time: i64, +) -> Option { + let dur = phase_duration_secs as u64; + let query = + format!("sum(rate({metric_base}_sum[{dur}s])) / sum(rate({metric_base}_count[{dur}s]))"); + query_prometheus_scalar(prometheus_url, &query, eval_time).await +} + +/// Time-aligned average block-gas fill percentage over the spammer's window. +/// Per-block average gas-used `/` block_gas_limit × 100, averaged across nodes. +/// +/// Same dilution problem as the block-timing histograms: the snapshot delta +/// path averages across ~340 s of subprocess wall-clock including idle +/// setup/teardown, so the resulting fill ratio understates how full blocks +/// actually were during the load window. +async fn query_block_fill_pct_aligned( + prometheus_url: &str, + phase_duration_secs: f64, + eval_time: i64, + block_gas_limit: u64, +) -> Option { + let dur = phase_duration_secs as u64; + let query = format!( + "sum(rate(arc_malachite_app_block_gas_used_sum[{dur}s])) \ + / sum(rate(arc_malachite_app_block_gas_used_count[{dur}s])) \ + / {block_gas_limit} * 100" + ); + query_prometheus_scalar(prometheus_url, &query, eval_time).await +} + +/// Query chain-confirmed gas-per-second over the spammer's sending window. +/// +/// Same approach as [`query_chain_actual_tps`] but on the block-gas histogram. +async fn query_chain_gas_per_sec( + prometheus_url: &str, + phase_duration_secs: f64, + eval_time: i64, +) -> Option { + let dur = phase_duration_secs as u64; + let query = format!("avg(rate(arc_malachite_app_block_gas_used_sum[{dur}s]))"); + query_prometheus_scalar(prometheus_url, &query, eval_time).await +} + +/// Query the peak value of a Reth subpool metric across all nodes during the +/// load window. `pool_name` is one of `pending|basefee|blob|queued`, +/// `metric_suffix` is `transactions` (count) or `size_bytes` (cumulative size). +/// +/// We need this because the geth-compat `txpool_status` RPC only returns +/// pending+queued counts; basefee and blob subpools are invisible at the RPC +/// layer, and no subpool exposes byte size via RPC at all. Reth exposes every +/// subpool's count *and* size as Prometheus gauges, so we read them here. +async fn query_subpool_peak( + prometheus_url: &str, + pool_name: &str, + metric_suffix: &str, + phase_duration_secs: f64, + eval_time: i64, +) -> Option { + let dur = phase_duration_secs as u64; + let query = format!( + "max(max_over_time(reth_transaction_pool_{pool_name}_pool_{metric_suffix}[{dur}s]))" + ); + query_prometheus_scalar(prometheus_url, &query, eval_time).await +} + +/// Query the average value of a Reth subpool metric across all nodes during +/// the load window. Companion to [`query_subpool_peak`]. +async fn query_subpool_avg( + prometheus_url: &str, + pool_name: &str, + metric_suffix: &str, + phase_duration_secs: f64, + eval_time: i64, +) -> Option { + let dur = phase_duration_secs as u64; + let query = format!( + "avg(avg_over_time(reth_transaction_pool_{pool_name}_pool_{metric_suffix}[{dur}s]))" + ); + query_prometheus_scalar(prometheus_url, &query, eval_time).await +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +pub(crate) async fn dispatch(testnet: Testnet, command: RunSubcommand) -> Result<()> { + match command { + RunSubcommand::Saturation { args } => run_saturation(testnet, args).await, + } +} + +/// Read-only inputs threaded through every phase of a saturation run. +/// Mutable per-phase state (generator state, remote state path) is passed +/// separately via `PhaseLoopState`. +struct SaturationCtx<'a> { + testnet: &'a Testnet, + args: &'a SaturationArgs, + block_gas_limit: u64, + tx_type_mix: spammer::TxTypeMix, + guzzler_fn_weights: spammer::GuzzlerFnWeights, + erc20_fn_weights: spammer::Erc20FnWeights, + tx_input_size: usize, + out_dir: PathBuf, + deadline: Instant, + num_generators: usize, + max_accounts: usize, + spammer_nodes: Vec, + target_ws_urls: Vec<(String, Url)>, + rpc_urls: Vec<(String, Url)>, + metrics_urls: Vec<(String, Url)>, + prometheus_url: String, + is_remote: bool, + remote_state_dir: &'static str, +} + +/// Mutable phase-to-phase state — local generator handles (for in-process +/// resume) and the remote-mode state-file path written by the previous phase. +struct PhaseLoopState { + generator_state: Option, + remote_state_in_path: Option, +} + +async fn run_saturation(testnet: Testnet, args: SaturationArgs) -> Result<()> { + let rates = parse_rates(&args.rates)?; + let (mut meta, ctx) = init_saturation(&testnet, &args, &rates)?; + save_experiment_json(&ctx.out_dir, &meta)?; + + preflight_checks(ctx.testnet).await?; + + let mut phase_state = run_rampup_phase(&ctx, rates[0]).await?; + + let mut experiment_status = ExperimentStatus::Completed; + for rate in &rates { + if Instant::now() >= ctx.deadline { + info!("Max duration reached, stopping before phase {rate} TPS"); + experiment_status = ExperimentStatus::TimedOut; + break; + } + let prev = meta.phases.last().cloned(); + let record = run_one_phase(&ctx, *rate, prev.as_ref(), &mut phase_state).await?; + meta.phases.push(record); + print_phase_table(&meta.phases); + save_experiment_json(&ctx.out_dir, &meta)?; + } + + finalize_experiment(&ctx, &mut meta, experiment_status).await +} + +/// Resolve CLI inputs into a [`SaturationCtx`] and build the initial +/// [`ExperimentMetadata`] record. Reaches out to the genesis file and the +/// node URL helpers to populate routing, so the testnet must be deployed — +/// but consensus health is checked separately by [`preflight_checks`]. +fn init_saturation<'a>( + testnet: &'a Testnet, + args: &'a SaturationArgs, + rates: &[u64], +) -> Result<(ExperimentMetadata, SaturationCtx<'a>)> { + // Pull the chain's actual gas limit from the manifest the testnet was + // started with — single source of truth for both the genesis + // ProtocolConfig (what the chain enforces) and the runner's `Fill%` + // denominator (what the table reports). Previously the runner took a + // separate `--block-gas-limit` CLI flag defaulting to 30M, which made + // every bumped-gas-limit experiment require a matching --flag value + // and would silently produce `Fill% > 100%` if the caller forgot. + let block_gas_limit = testnet + .manifest + .block_gas_limit + .unwrap_or(DEFAULT_BLOCK_GAS_LIMIT); + + let experiment_id = format!("saturation-{}", Utc::now().format("%Y%m%dT%H%M%SZ")); + let out_dir = args.output_dir.join(&experiment_id); + fs::create_dir_all(&out_dir)?; + + let deadline = Instant::now() + args.max_duration; + + // Resolve mix / weights / input size up front so experiment.json records the + // values that actually ran (not the compile-time defaults). CLI overrides + // win; otherwise fall back to the saturation defaults shared across phases. + let tx_type_mix = match args.tx_type_mix { + Some(m) => m, + None => TX_MIX.parse().map_err(|e: String| eyre!("{e}"))?, + }; + let guzzler_fn_weights = match args.guzzler_fn_weights { + Some(w) => w, + None => GUZZLER_FN_WEIGHTS + .parse() + .map_err(|e: String| eyre!("{e}"))?, + }; + let erc20_fn_weights = match args.erc20_fn_weights { + Some(w) => w, + None => ERC20_FN_WEIGHTS.parse().map_err(|e: String| eyre!("{e}"))?, + }; + let tx_input_size = args.tx_input_size; + + let manifest_json = { + let path = &testnet.manifest_path; + let toml_str = fs::read_to_string(path) + .wrap_err_with(|| format!("read manifest at {}", path.display()))?; + let toml_value: toml::Value = toml::from_str(&toml_str) + .wrap_err_with(|| format!("parse manifest at {}", path.display()))?; + serde_json::to_value(toml_value) + .wrap_err("convert manifest TOML to JSON for experiment report")? + }; + + let meta = ExperimentMetadata { + experiment_id, + parameters: SaturationParameters { + rates: rates.to_vec(), + hold_secs: args.phase_duration.as_secs(), + rampup_secs: args.rampup.as_secs(), + cooldown_secs: args.cooldown.as_secs(), + generators: args.generators, + tx_mix: format_tx_type_mix(&tx_type_mix), + guzzler_fn_weights: format_guzzler_fn_weights(&guzzler_fn_weights), + erc20_fn_weights: format_erc20_fn_weights(&erc20_fn_weights), + manifest: manifest_json, + }, + // Written early so a partial record exists even on failure + status: ExperimentStatus::Failed { + reason: "experiment did not complete".into(), + }, + started_at: Utc::now(), + ended_at: None, + phases: vec![], + }; + + let num_extra_accounts = genesis::num_prefunded_accounts( + &testnet.dir.join("assets").join("genesis.json"), + testnet.manifest.num_validators(), + )?; + let max_accounts = num_extra_accounts; + let num_generators = if args.generators > max_accounts { + warn!( + "Requested {} generators but only {} spammer accounts available; \ + capping generators. Re-deploy with more accounts to use more generators.", + args.generators, max_accounts + ); + max_accounts + } else { + args.generators + }; + + let selectors = args.targets.clone().unwrap_or_default(); + let spammer_nodes = crate::load::resolve_load_target_nodes(&testnet.manifest, &selectors)?; + let target_ws_urls = testnet.nodes_metadata.to_execution_ws_urls(&spammer_nodes); + // Mempool + RPC checks scan every node (not just spammer targets) so we + // observe the whole cluster's reaction to load applied at a subset. + let all_node_names = testnet.nodes_metadata.node_names(); + let rpc_urls = testnet + .nodes_metadata + .to_execution_http_urls(&all_node_names); + let metrics_urls = testnet.nodes_metadata.all_consensus_metrics_urls(); + + let (prometheus_port, _, _) = testnet.infra_data.monitoring_ports(); + let prometheus_url = format!("http://127.0.0.1:{prometheus_port}"); + + let ctx = SaturationCtx { + testnet, + args, + block_gas_limit, + tx_type_mix, + guzzler_fn_weights, + erc20_fn_weights, + tx_input_size, + out_dir, + deadline, + num_generators, + max_accounts, + spammer_nodes, + target_ws_urls, + rpc_urls, + metrics_urls, + prometheus_url, + is_remote: testnet.is_remote(), + remote_state_dir: "saturation_state", + }; + + Ok((meta, ctx)) +} + +async fn preflight_checks(testnet: &Testnet) -> Result<()> { + // The testnet must already be set up and running. We never start or stop + // the testnet here — operators control that lifecycle with `quake start` + // and `quake clean`. `is_setup` works for both local and remote testnets. + testnet.infra.is_setup(&[]).wrap_err_with(|| { + format!( + "Testnet at {} is not set up. Start it first with `quake start` (add `--remote` for AWS).", + testnet.dir.display() + ) + })?; + info!("Using running testnet at {}", testnet.dir.display()); + testnet.wait_rounds(3, Duration::from_secs(120)).await?; + + // Pre-experiment readiness gate: confirm the in-container tc netem rules + // match the manifest's latency_emulation expectation. A multi-region + // saturation run on a flat (no-tc-rules) cluster, or vice versa, would + // otherwise produce results that look fine but don't reflect the intended + // network conditions. + info!("Verifying latency-emulation setup matches manifest..."); + testnet + .run_tests( + "infra:latency_emulation", + false, + Duration::from_secs(30), + &crate::tests::TestParams::default(), + ) + .await + .wrap_err("pre-experiment latency_emulation check failed")?; + Ok(()) +} + +/// Run an actual-load warmup at the first rate so every account cycles +/// once before the first measured phase begins. Replaces the earlier +/// hard-coded sleep and eliminates the per-phase nonce-warmup dead zone +/// that arises when nonces are queried lazily inside `next_tx()`. +async fn run_rampup_phase(ctx: &SaturationCtx<'_>, first_rate: u64) -> Result { + info!( + "Ramp-up: running at {} TPS for {}s to warm generators and txpool...", + first_rate, + ctx.args.rampup.as_secs() + ); + let warmup_config = spammer::Config { + num_generators: ctx.num_generators, + partition_mode: spammer::PartitionMode::Linear, + max_num_accounts: ctx.max_accounts, + preinit_accounts: false, + query_latest_nonce: true, + max_num_txs: 0, + max_rate: first_rate, + max_time: ctx.args.rampup.as_secs(), + tx_input_size: ctx.tx_input_size, + max_txs_per_account: 0, + silent: false, + show_pool_status: false, + tx_latency: false, + wait_response: false, + fire_and_forget: true, + reconnect_attempts: 3, + reconnect_period: Duration::from_secs(3), + tx_type_mix: ctx.tx_type_mix, + guzzler_fn_weights: ctx.guzzler_fn_weights, + erc20_fn_weights: ctx.erc20_fn_weights, + csv_dir: None, + }; + warmup_config.validate()?; + + // Remote-mode state persistence: the spammer subprocess on each phase + // writes its captured SpammerState to a JSON file on CC; the next phase's + // subprocess reads it back via --state-in. Avoids the BIP32-derivation + + // nonce-query startup cost (~50s for 50k accounts) at the start of every + // phase. The path is tracked across iterations of the phase loop. + let remote_ramp_up_state = format!("{}/ramp-up.json", ctx.remote_state_dir); + let mut state = PhaseLoopState { + generator_state: None, + remote_state_in_path: None, + }; + if ctx.is_remote { + let warmup_args = build_phase_spammer_args( + &warmup_config, + ctx.tx_type_mix, + ctx.guzzler_fn_weights, + ctx.erc20_fn_weights, + None, + None, + None, + Some(remote_ramp_up_state.clone()), + ); + run_phase_remote(ctx.testnet, warmup_args, &ctx.spammer_nodes, None).await?; + state.remote_state_in_path = Some(remote_ramp_up_state); + } else { + let warmup_load = spammer::Spammer::new(ctx.target_ws_urls.clone(), &warmup_config).await?; + state.generator_state = Some(warmup_load.run_capturing_state().await?.state); + } + + // Wait for the mempool to drain naturally before the first measured phase. + // Clear any residue if the drain times out so the first phase starts on + // an empty mempool (the unconditional per-phase resync handles cache + // alignment regardless). + info!( + "Ramp-up complete. Draining mempool (up to {}s) before first phase...", + ctx.args.cooldown.as_secs() + ); + if !wait_for_mempool_drain(&ctx.rpc_urls, ctx.args.cooldown).await { + warn!( + "Ramp-up: mempool did not drain within {}s — clearing before first phase", + ctx.args.cooldown.as_secs() + ); + clear_mempools(&ctx.rpc_urls).await; + } + Ok(state) +} + +/// Run a single rate phase end-to-end: spammer load, Prometheus-aligned +/// metric collection, mempool drain, latency CSV parse, and saturation-signal +/// detection. Returns the full [`PhaseRecord`] for the caller to append. +async fn run_one_phase( + ctx: &SaturationCtx<'_>, + rate: u64, + prev_phase: Option<&PhaseRecord>, + state: &mut PhaseLoopState, +) -> Result { + let phase_started_at = Utc::now(); + info!( + "Phase {rate} TPS: running for {}s", + ctx.args.phase_duration.as_secs() + ); + + // Per-phase CSV directory; the spammer creates a timestamped file inside + let phase_csv_dir = ctx.out_dir.join(format!("phase_{rate}")); + + let spammer_config = spammer::Config { + num_generators: ctx.num_generators, + partition_mode: spammer::PartitionMode::Linear, + max_num_accounts: ctx.max_accounts, + preinit_accounts: false, + query_latest_nonce: true, + max_num_txs: 0, + max_rate: rate, + max_time: ctx.args.phase_duration.as_secs(), + tx_input_size: ctx.tx_input_size, + max_txs_per_account: 0, + silent: false, + show_pool_status: false, + tx_latency: true, + wait_response: false, + fire_and_forget: true, + reconnect_attempts: 3, + reconnect_period: Duration::from_secs(3), + tx_type_mix: ctx.tx_type_mix, + guzzler_fn_weights: ctx.guzzler_fn_weights, + erc20_fn_weights: ctx.erc20_fn_weights, + csv_dir: Some(phase_csv_dir.clone()), + }; + spammer_config.validate()?; + let raw_before = fetch_all_metrics(&ctx.metrics_urls).await; + let load_started_at = Utc::now(); + + let phase_outcome = if ctx.is_remote { + let phase_dir = format!("saturation_csvs/phase_{rate}"); + let phase_state_out = format!("{}/phase_{rate}.json", ctx.remote_state_dir); + let phase_args = build_phase_spammer_args( + &spammer_config, + ctx.tx_type_mix, + ctx.guzzler_fn_weights, + ctx.erc20_fn_weights, + Some(phase_dir.clone()), + Some(format!("{phase_dir}/summary.json")), + state.remote_state_in_path.clone(), + Some(phase_state_out.clone()), + ); + let outcome = run_phase_remote( + ctx.testnet, + phase_args, + &ctx.spammer_nodes, + Some(&phase_csv_dir), + ) + .await?; + // Next phase reads this phase's state file as its --state-in. + state.remote_state_in_path = Some(phase_state_out); + outcome + } else { + let load = spammer::Spammer::new_resuming( + ctx.target_ws_urls.clone(), + state + .generator_state + .take() + .expect("local mode keeps state"), + &spammer::ResumeConfig::from(&spammer_config), + ) + .await?; + let (outcome, returned_state) = run_phase_capturing_state(load).await?; + state.generator_state = Some(returned_state); + outcome + }; + let PhaseOutcome { + rpc_errors, + actual_offered_tps, + actual_offered_bytes_per_sec, + started_at_unix_ms: spammer_started_at_unix_ms, + finished_at_unix_ms: spammer_finished_at_unix_ms, + } = phase_outcome; + let load_ended_at = Utc::now(); + let wall_clock_phase_secs = + (load_ended_at - load_started_at).num_milliseconds() as f64 / 1000.0; + // The spammer-reported window (started→finished, in unix ms) excludes + // setup overhead (SSH session, nonce-warmup, CSV SCP-back in remote + // mode). Using it for both the chain-rate denominator and the + // Prometheus query window keeps numerator and denominator aligned. + let spammer_elapsed = + ((spammer_finished_at_unix_ms - spammer_started_at_unix_ms) as f64 / 1000.0).max(0.0); + let phase_duration_secs = if spammer_elapsed > 0.0 { + spammer_elapsed + } else { + wall_clock_phase_secs + }; + + // Prometheus snapshot after load (before cooldown to capture steady state). + // Query EL CPU/memory concurrently — they use the Prometheus API, not a direct scrape. + // Also query chain-side TPS and gas/s aligned with the spammer's exact + // window (started_at..finished_at), so numerator and denominator share + // the same time interval and we avoid wall-clock setup-overhead bias. + let eval_time_secs = if spammer_finished_at_unix_ms > 0 { + spammer_finished_at_unix_ms / 1000 + } else { + load_ended_at.timestamp() + }; + // Reth exposes per-subpool `_pool_transactions` (count) and + // `_pool_size_bytes` (cumulative wire size) as Prometheus gauges. Read + // all of them so the table can show "count(MB)" for every subpool — + // pending/queued count is duplicated with the RPC sample, but having + // size paired up matters for diagnosing which limit dimension is + // actually binding when "txpool is full" fires. + let pq = |pool: &'static str, metric: &'static str| { + query_subpool_peak( + &ctx.prometheus_url, + pool, + metric, + phase_duration_secs, + eval_time_secs, + ) + }; + let aq = |pool: &'static str, metric: &'static str| { + query_subpool_avg( + &ctx.prometheus_url, + pool, + metric, + phase_duration_secs, + eval_time_secs, + ) + }; + let ( + raw_after, + (el_cpu_avg_pct, el_cpu_max_pct), + (cl_cpu_avg_pct, cl_cpu_max_pct), + (el_mem_avg_mb, el_mem_peak_mb), + pool_evictions, + chain_tps_aligned, + chain_gas_per_sec_aligned, + block_time_aligned, + block_build_time_aligned_s, + block_finalize_time_aligned_s, + fill_pct_aligned, + max_pending_mempool_prom, + avg_pending_mempool_prom, + max_queued_mempool_prom, + avg_queued_mempool_prom, + max_basefee_mempool, + avg_basefee_mempool, + max_blob_mempool, + avg_blob_mempool, + max_pending_size_bytes, + avg_pending_size_bytes, + max_basefee_size_bytes, + avg_basefee_size_bytes, + max_queued_size_bytes, + avg_queued_size_bytes, + max_blob_size_bytes, + avg_blob_size_bytes, + ) = join!( + fetch_all_metrics(&ctx.metrics_urls), + query_el_cpu_pct(&ctx.prometheus_url, phase_duration_secs, eval_time_secs), + query_cl_cpu_pct(&ctx.prometheus_url, phase_duration_secs, eval_time_secs), + query_el_memory_mb(&ctx.prometheus_url, eval_time_secs), + query_pool_evictions(&ctx.prometheus_url, phase_duration_secs, eval_time_secs), + query_chain_actual_tps(&ctx.prometheus_url, phase_duration_secs, eval_time_secs), + query_chain_gas_per_sec(&ctx.prometheus_url, phase_duration_secs, eval_time_secs), + query_block_histogram_avg( + &ctx.prometheus_url, + "arc_malachite_app_block_time", + phase_duration_secs, + eval_time_secs, + ), + query_block_histogram_avg( + &ctx.prometheus_url, + "arc_malachite_app_block_build_time", + phase_duration_secs, + eval_time_secs, + ), + query_block_histogram_avg( + &ctx.prometheus_url, + "arc_malachite_app_block_finalize_time", + phase_duration_secs, + eval_time_secs, + ), + query_block_fill_pct_aligned( + &ctx.prometheus_url, + phase_duration_secs, + eval_time_secs, + ctx.block_gas_limit, + ), + pq("pending", "transactions"), + aq("pending", "transactions"), + pq("queued", "transactions"), + aq("queued", "transactions"), + pq("basefee", "transactions"), + aq("basefee", "transactions"), + pq("blob", "transactions"), + aq("blob", "transactions"), + pq("pending", "size_bytes"), + aq("pending", "size_bytes"), + pq("basefee", "size_bytes"), + aq("basefee", "size_bytes"), + pq("queued", "size_bytes"), + aq("queued", "size_bytes"), + pq("blob", "size_bytes"), + aq("blob", "size_bytes"), + ); + const BYTES_PER_MB: f64 = 1_048_576.0; + let to_mb = |b: Option| b.map(|v| v / BYTES_PER_MB); + let max_pending_size_mb = to_mb(max_pending_size_bytes); + let avg_pending_size_mb = to_mb(avg_pending_size_bytes); + let max_basefee_size_mb = to_mb(max_basefee_size_bytes); + let avg_basefee_size_mb = to_mb(avg_basefee_size_bytes); + let max_queued_size_mb = to_mb(max_queued_size_bytes); + let avg_queued_size_mb = to_mb(avg_queued_size_bytes); + let max_blob_size_mb = to_mb(max_blob_size_bytes); + let avg_blob_size_mb = to_mb(avg_blob_size_bytes); + + let perf_deltas = parse_perf_metrics_delta(&raw_before, &raw_after); + let health_before = parse_all_health_metrics(&raw_before); + let health_after = parse_all_health_metrics(&raw_after); + let health_deltas = compute_health_deltas(&health_before, &health_after); + + let mut metrics = aggregate_phase_metrics( + &perf_deltas, + &health_deltas, + ctx.block_gas_limit, + phase_duration_secs, + ); + // Override the rate-sensitive metrics with the time-aligned versions. + // The snapshot deltas above cover a slightly wider wall-clock window + // than the spammer's actual sending window; the Prometheus query is + // restricted to that exact window via `increase(metric[duration])`. + if let Some(tps) = chain_tps_aligned { + metrics.actual_tps = Some(tps); + } + if let Some(gas) = chain_gas_per_sec_aligned { + metrics.gas_per_sec = Some(gas); + } + // The snapshot delta spans `raw_before → raw_after` (~340 s including + // setup + load + teardown), so its block-timing averages get diluted + // by the fast pre-load and post-load blocks. The Prometheus rate + // query is restricted to the spammer's actual 180 s load window, + // matching what the chart shows for the same phase region. + if let Some(bt) = block_time_aligned { + metrics.avg_block_time_s = Some(bt); + } + if let Some(bld) = block_build_time_aligned_s { + metrics.avg_block_build_time_ms = Some(bld * 1000.0); + } + if let Some(fin) = block_finalize_time_aligned_s { + metrics.avg_block_finalize_time_ms = Some(fin * 1000.0); + } + if let Some(fp) = fill_pct_aligned { + metrics.fill_pct = Some(fp); + } + // Pending and queued count come from Prometheus too, alongside size. + // Sourcing them from the runner's in-loop `txpool_status` RPC poll + // was unreliable under load — the busiest node's RPC handler stalls + // exactly when it has the most queued txs, and the poll silently + // logged it as zero. Using Prometheus keeps these on the same data + // source as the per-subpool size and the chart in the HTML report. + metrics.max_mempool = max_pending_mempool_prom; + metrics.avg_pending_mempool = avg_pending_mempool_prom; + metrics.max_queued_mempool = max_queued_mempool_prom; + metrics.avg_queued_mempool = avg_queued_mempool_prom; + metrics.max_basefee_mempool = max_basefee_mempool; + metrics.avg_basefee_mempool = avg_basefee_mempool; + metrics.max_blob_mempool = max_blob_mempool; + metrics.avg_blob_mempool = avg_blob_mempool; + metrics.max_pending_size_mb = max_pending_size_mb; + metrics.avg_pending_size_mb = avg_pending_size_mb; + metrics.max_basefee_size_mb = max_basefee_size_mb; + metrics.avg_basefee_size_mb = avg_basefee_size_mb; + metrics.max_queued_size_mb = max_queued_size_mb; + metrics.avg_queued_size_mb = avg_queued_size_mb; + metrics.max_blob_size_mb = max_blob_size_mb; + metrics.avg_blob_size_mb = avg_blob_size_mb; + metrics.pool_evictions = pool_evictions; + metrics.el_cpu_avg_pct = el_cpu_avg_pct; + metrics.el_cpu_max_pct = el_cpu_max_pct; + metrics.cl_cpu_avg_pct = cl_cpu_avg_pct; + metrics.cl_cpu_max_pct = cl_cpu_max_pct; + metrics.el_mem_avg_mb = el_mem_avg_mb; + metrics.el_mem_peak_mb = el_mem_peak_mb; + metrics.rpc_errors = rpc_errors; + metrics.actual_offered_tps = Some(actual_offered_tps); + metrics.actual_offered_bytes_per_sec = Some(actual_offered_bytes_per_sec); + + // Wait for the chain to absorb the phase's in-flight backlog (up to + // `cooldown`) so the next phase starts on an empty mempool. Clear any + // residue if the drain times out; the next phase's unconditional + // resync handles cache alignment either way. Latency CSV parsing runs + // concurrently because it's local file IO and effectively free. + info!( + "Phase {rate} TPS: draining mempool (up to {}s)...", + ctx.args.cooldown.as_secs() + ); + let (latency_stats, drained) = join!( + async { + let Some(csv_path) = find_latest_csv(&phase_csv_dir) else { + return (None, None, None, None); + }; + match read_latency_csv(&csv_path) { + Ok(latencies) => compute_latency_stats(&latencies), + Err(e) => { + warn!("Could not read latency CSV {}: {e}", csv_path.display()); + (None, None, None, None) + } + } + }, + wait_for_mempool_drain(&ctx.rpc_urls, ctx.args.cooldown), + ); + + if !drained { + warn!( + "Phase {rate} TPS: mempool did not drain within {}s — clearing residue before next phase", + ctx.args.cooldown.as_secs() + ); + clear_mempools(&ctx.rpc_urls).await; + } + let (avg, stddev, p50, p95) = latency_stats; + metrics.latency_avg_ms = avg; + metrics.latency_stddev_ms = stddev; + metrics.latency_p50_ms = p50; + metrics.latency_p95_ms = p95; + let ended_at = Utc::now(); + + let mut record = PhaseRecord { + offered_tps: rate, + started_at: phase_started_at, + load_ended_at, + ended_at, + metrics, + signals: vec![], + }; + record.signals = detect_saturation_signals(prev_phase, &record); + Ok(record) +} + +async fn finalize_experiment( + ctx: &SaturationCtx<'_>, + meta: &mut ExperimentMetadata, + status: ExperimentStatus, +) -> Result<()> { + meta.status = status; + meta.ended_at = Some(Utc::now()); + + download_metrics_snapshot(ctx.testnet, meta, &ctx.out_dir).await; + + save_experiment_json(&ctx.out_dir, meta)?; + info!("Experiment complete. Results: {}", ctx.out_dir.display()); + Ok(()) +} + +// ── Phase lifecycle helpers ─────────────────────────────────────────────────── + +/// Call `admin_cleartxpool` on every node in parallel. +/// +/// Best-effort: per-node failures are logged but never propagated, so a single +/// flaky node can't abort the experiment. The aggregate failure count is logged +/// at debug to keep the phase output clean when nodes briefly stop responding. +async fn clear_mempools(rpc_urls: &[(String, Url)]) { + use alloy_provider::{Provider, ProviderBuilder}; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client"); + + let calls = rpc_urls.iter().map(|(name, url)| { + let client = client.clone(); + async move { + let provider = ProviderBuilder::new().connect_reqwest(client, url.clone()); + match provider + .raw_request::<_, bool>("admin_cleartxpool".into(), ()) + .await + { + Ok(_) => true, + Err(e) => { + debug!("admin_cleartxpool on {name} failed: {e}"); + false + } + } + } + }); + let results = futures::future::join_all(calls).await; + let cleared = results.iter().filter(|ok| **ok).count(); + debug!("Cleared mempool on {cleared}/{} nodes", rpc_urls.len()); +} + +/// Poll `txpool_status` on every node until the aggregate mempool drains, or +/// `timeout` expires. Returns `true` if every reachable node reported +/// `pending == 0 && queued == 0` at the same poll, `false` if the timeout fired. +/// +/// Saturation phases that overshoot the chain ceiling leave a residual +/// (queued-pool nonce gap or pending backlog) that would carry into the next +/// phase, biasing its mempool signals and silently diverging the spammer's +/// cached nonces from on-chain state. A natural drain confirms that every +/// in-flight tx executed (or was evicted) so the spammer's cache matches the +/// chain — letting the next phase skip the expensive per-account resync. A +/// timeout is the diagnostic signal that this phase exceeded capacity, and the +/// caller should clear + force a resync to recover before the next phase. +async fn wait_for_mempool_drain(rpc_urls: &[(String, Url)], timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let poll_interval = Duration::from_millis(500); + loop { + if matches!(check_mempool(rpc_urls).await, Ok(r) if r.passed()) { + return true; + } + let now = Instant::now(); + if now >= deadline { + return false; + } + sleep(poll_interval.min(deadline - now)).await; + } +} + +/// Aggregated result of a single saturation phase, shared by the local +/// (`run_phase_capturing_state`) and remote (`run_phase_remote`) paths. +/// +/// The spamming window (`started_at_unix_ms`..`finished_at_unix_ms`) is +/// surfaced so the caller can align chain-side metrics (Prometheus delta +/// queries) with the actual sending window — excludes the setup overhead +/// bled into wall-clock. +/// +struct PhaseOutcome { + rpc_errors: HashMap, + actual_offered_tps: f64, + actual_offered_bytes_per_sec: f64, + started_at_unix_ms: i64, + finished_at_unix_ms: i64, +} + +/// Run the spammer for one phase and return the outcome plus the generator +/// state so the next phase can resume from it (local mode only — +/// remote-mode state lives in a JSON file on CC). +async fn run_phase_capturing_state( + load: spammer::Spammer, +) -> Result<(PhaseOutcome, spammer::SpammerState)> { + let result = load.run_capturing_state().await?; + Ok(( + PhaseOutcome { + rpc_errors: result.rpc_errors, + actual_offered_tps: result.actual_offered_tps, + actual_offered_bytes_per_sec: result.actual_offered_bytes_per_sec, + started_at_unix_ms: result.started_at_unix_ms, + finished_at_unix_ms: result.finished_at_unix_ms, + }, + result.state, + )) +} + +/// Build `SpammerArgs` from a phase's `spammer::Config` for remote invocation. +#[allow(clippy::too_many_arguments)] +fn build_phase_spammer_args( + config: &spammer::Config, + tx_type_mix: spammer::TxTypeMix, + guzzler_fn_weights: spammer::GuzzlerFnWeights, + erc20_fn_weights: spammer::Erc20FnWeights, + csv_dir: Option, + summary_json: Option, + state_in: Option, + state_out: Option, +) -> SpammerArgs { + SpammerArgs { + num_generators: config.num_generators, + max_num_accounts: config.max_num_accounts, + partition_mode: config.partition_mode, + num_txs: config.max_num_txs, + rate: config.max_rate, + time: config.max_time, + tx_input_size: config.tx_input_size, + max_txs_per_account: config.max_txs_per_account, + preinit_accounts: config.preinit_accounts, + query_latest_nonce: config.query_latest_nonce, + silent: config.silent, + show_pool_status: config.show_pool_status, + tx_latency: config.tx_latency, + csv_dir: csv_dir.map(PathBuf::from), + summary_json: summary_json.map(PathBuf::from), + state_in: state_in.map(PathBuf::from), + state_out: state_out.map(PathBuf::from), + wait_response: config.wait_response, + reconnect_attempts: config.reconnect_attempts, + reconnect_period: config.reconnect_period, + tx_type_mix: Some(tx_type_mix), + guzzler_fn_weights, + erc20_fn_weights: Some(erc20_fn_weights), + } +} + +/// Run one phase on the Control Center via SSH. Mempool stats are sourced +/// from Prometheus post-phase, not from RPC sampling here — `txpool_status` +/// silently returns zero on stalled-under-load nodes, exactly the regime +/// we're trying to measure. +async fn run_phase_remote( + testnet: &Testnet, + spammer_args: SpammerArgs, + spammer_nodes: &[String], + local_csv_dir: Option<&Path>, +) -> Result { + let infra = testnet.remote_infra()?; + + let mut cli_args = spammer_args.to_cli_args(); + if !spammer_nodes.is_empty() { + cli_args.push("--targets".to_string()); + cli_args.push(spammer_nodes.join(",")); + } + let cmd_parts = crate::load::build_remote_spammer_cmd(&testnet.manifest, &cli_args, true)?; + let cmd = cmd_parts.join(" "); + + let remote_csv_dir = spammer_args + .csv_dir + .as_ref() + .map(|p| p.to_string_lossy().into_owned()); + let remote_summary_path = spammer_args + .summary_json + .as_ref() + .map(|p| p.to_string_lossy().into_owned()); + // Ensure the spammer can write its outputs — create the parent dir for + // each configured path on CC. The summary file's parent is what the + // spammer's main writes to; the CSV dir is what the latency CSV needs. + let mut mkdir_paths: Vec = Vec::new(); + if let Some(ref dir) = remote_csv_dir { + mkdir_paths.push(dir.clone()); + } + if let Some(ref path) = remote_summary_path { + if let Some(parent) = Path::new(path).parent() { + let p = parent.to_string_lossy().into_owned(); + if !p.is_empty() && !mkdir_paths.contains(&p) { + mkdir_paths.push(p); + } + } + } + if !mkdir_paths.is_empty() { + let cmd = format!("mkdir -p {}", mkdir_paths.join(" ")); + infra.ssh_cc(&cmd, false)?; + } + + let phase_prefix = format!("spammer@{}TPS", spammer_args.rate); + info!("{phase_prefix}: dispatching spammer subprocess on CC"); + let dispatch_start = Instant::now(); + + let infra_for_ssh = infra.clone(); + let cmd_for_ssh = cmd.clone(); + let prefix_for_ssh = phase_prefix.clone(); + let stdout = tokio::task::spawn_blocking(move || { + infra_for_ssh.ssh_cc_with_streaming_output(&cmd_for_ssh, &prefix_for_ssh) + }) + .await + .wrap_err("spawn_blocking for spammer SSH panicked")? + .wrap_err("remote spammer exited with error")?; + info!( + "{phase_prefix}: subprocess returned after {:.1}s", + dispatch_start.elapsed().as_secs_f64() + ); + + if let (Some(remote_dir), Some(local_dir)) = (remote_csv_dir.as_deref(), local_csv_dir) { + if let Err(e) = std::fs::create_dir_all(local_dir) { + warn!( + "Could not create local CSV dir {}: {e}", + local_dir.display() + ); + } + let pattern = format!("{remote_dir}/tx_latency_*.csv"); + let scp_csv_start = Instant::now(); + if let Err(e) = infra.scp_from_cc(&pattern, local_dir) { + warn!("SCP of latency CSV from CC ({pattern}) failed: {e}"); + } else { + info!( + "{phase_prefix}: SCP'd latency CSVs in {:.1}s", + scp_csv_start.elapsed().as_secs_f64() + ); + } + } + + // Warmup runs without a summary_json (the metrics it produces are + // discarded), so there's no file to fetch. Real phases always set it. + let (rpc_errors, actual_tps, actual_bps, started_at_unix_ms, finished_at_unix_ms) = + match remote_summary_path.as_deref() { + Some(path) => { + let scp_summary_start = Instant::now(); + let summary = read_spammer_summary(&infra, path).wrap_err_with(|| { + format!( + "could not recover spammer summary from CC\n--- spammer stdout (tail) ---\n{}", + stdout_tail(&stdout, 60) + ) + })?; + info!( + "{phase_prefix}: SCP'd summary in {:.1}s", + scp_summary_start.elapsed().as_secs_f64() + ); + ( + summary.rpc_errors, + summary.actual_offered_tps, + summary.actual_offered_bytes_per_sec, + summary.started_at_unix_ms, + summary.finished_at_unix_ms, + ) + } + None => (HashMap::new(), 0.0, 0.0, 0, 0), + }; + + Ok(PhaseOutcome { + rpc_errors, + actual_offered_tps: actual_tps, + actual_offered_bytes_per_sec: actual_bps, + started_at_unix_ms, + finished_at_unix_ms, + }) +} + +/// SCP `summary.json` from the spammer's CSV directory on CC and deserialize it. +/// +/// Returns an error if the file is missing or malformed — the saturation +/// runner can't compute phase metrics without it, so failing loudly is the +/// right behavior. The wrapped stdout tail makes diagnosis easier when the +/// spammer crashed before writing the summary. +fn read_spammer_summary( + infra: &crate::infra::remote::RemoteInfra, + remote_summary_path: &str, +) -> Result { + let tmp = tempfile::Builder::new() + .prefix("quake-spammer-summary-") + .tempdir() + .wrap_err("could not create temp dir for spammer summary")?; + infra + .scp_from_cc(remote_summary_path, tmp.path()) + .wrap_err_with(|| format!("scp summary from CC ({remote_summary_path}) failed"))?; + let file_name = Path::new(remote_summary_path) + .file_name() + .ok_or_else(|| eyre!("remote summary path has no file name: {remote_summary_path}"))?; + let local_path = tmp.path().join(file_name); + let bytes = std::fs::read(&local_path) + .wrap_err_with(|| format!("read {} failed", local_path.display()))?; + serde_json::from_slice::(&bytes) + .wrap_err("deserialize spammer summary JSON") +} + +fn stdout_tail(stdout: &str, max_lines: usize) -> String { + let lines: Vec<&str> = stdout.lines().collect(); + let start = lines.len().saturating_sub(max_lines); + lines[start..].join("\n") +} + +/// Download a targeted Prometheus metrics snapshot for the experiment window. +/// +/// Works identically for local and remote testnets — both expose Prometheus +/// at `127.0.0.1:` (Docker port locally, SSM-tunnelled port remotely), +/// so a single Rust HTTP path handles both. Best-effort: errors are logged +/// and swallowed so a missing tarball never fails the experiment. +async fn download_metrics_snapshot(testnet: &Testnet, meta: &ExperimentMetadata, out_dir: &Path) { + let Some(ended_at) = meta.ended_at else { + warn!("No ended_at timestamp — skipping metrics download"); + return; + }; + let duration_secs = (ended_at - meta.started_at).num_seconds().max(1); + let step_secs = (duration_secs / 1000).max(15); + let step = if step_secs < 60 { + format!("{step_secs}s") + } else { + format!("{}m", step_secs / 60) + }; + let (prometheus_port, _, _) = testnet.infra_data.monitoring_ports(); + let prometheus_url = format!("http://127.0.0.1:{prometheus_port}"); + let dest = out_dir.join("metrics.tar.gz"); + + info!("Downloading metrics..."); + if let Err(e) = crate::metrics::download_to_tarball( + &prometheus_url, + DOWNLOAD_METRICS, + Some(meta.started_at.timestamp()), + Some(ended_at.timestamp()), + Some(&step), + &dest, + ) + .await + { + warn!("Could not download metrics: {e}"); + } +} + +fn save_experiment_json(out_dir: &Path, meta: &ExperimentMetadata) -> Result<()> { + let path = out_dir.join("experiment.json"); + let json = serde_json::to_string_pretty(meta)?; + write_atomic(&json, &path).wrap_err_with(|| format!("write {}", path.display())) +} + +/// Write a file via tempfile + rename so a crash mid-write can't leave a +/// truncated `experiment.json` behind. Mirrors the spammer's pattern for +/// `summary.json` / `state.json`. +fn write_atomic(contents: &str, path: &Path) -> Result<()> { + use std::io::Write; + let parent = path.parent().filter(|p| !p.as_os_str().is_empty()); + if let Some(p) = parent { + fs::create_dir_all(p).wrap_err_with(|| format!("create parent dir {}", p.display()))?; + } + let dir = parent.unwrap_or_else(|| Path::new(".")); + let mut tmp = tempfile::NamedTempFile::new_in(dir) + .wrap_err_with(|| format!("create tempfile in {}", dir.display()))?; + tmp.write_all(contents.as_bytes()) + .wrap_err("write tempfile")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tmp.as_file() + .set_permissions(std::fs::Permissions::from_mode(0o644)) + .wrap_err("chmod tempfile")?; + } + tmp.persist(path) + .map_err(|e| eyre!("persist tempfile to {}: {}", path.display(), e))?; + Ok(()) +} + +/// Return the path to the most recently modified `tx_latency_*.csv` in `dir`. +fn find_latest_csv(dir: &Path) -> Option { + fs::read_dir(dir) + .ok()? + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .map(|n| n.starts_with("tx_latency_") && n.ends_with(".csv")) + .unwrap_or(false) + }) + .max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok()) + .map(|e| e.path()) +} + +/// Upper bound on the number of phases a parsed `--rates` may expand to. +/// Defense against typos like `1-1000000:1` that would produce a million +/// phases — saturation experiments are normally 10–30. +const MAX_RATES_EXPANDED: usize = 256; + +/// Parse one comma-delimited token of `--rates`. Accepts either a bare +/// integer (`1500`) or an inclusive range with step (`1000-2000:100`). +/// +/// Range invariants enforced: `start > 0`, `end > start` (strict — use the +/// bare integer form for a single rate), `step > 0`, `step <= end - start` +/// (step must fit in the range), and `(end - start) % step == 0` (so the +/// endpoint `end` is actually reached and isn't silently dropped). +fn parse_rate_token(token: &str) -> Result> { + let token = token.trim(); + let Some((range_part, step_part)) = token.split_once(':') else { + let v: u64 = token + .parse() + .map_err(|e| eyre!("invalid rate '{token}': {e}"))?; + if v == 0 { + bail!("rate '{token}' must be > 0"); + } + return Ok(vec![v]); + }; + let (start_str, end_str) = range_part + .split_once('-') + .ok_or_else(|| eyre!("range '{token}' must be START-END:STEP"))?; + let start: u64 = start_str + .trim() + .parse() + .map_err(|e| eyre!("invalid start '{start_str}' in range '{token}': {e}"))?; + let end: u64 = end_str + .trim() + .parse() + .map_err(|e| eyre!("invalid end '{end_str}' in range '{token}': {e}"))?; + let step: u64 = step_part + .trim() + .parse() + .map_err(|e| eyre!("invalid step '{step_part}' in range '{token}': {e}"))?; + if start == 0 { + bail!("range '{token}' has start = 0; rates must be > 0"); + } + if step == 0 { + bail!("range '{token}' has step = 0"); + } + if end <= start { + bail!( + "range '{token}' has end ({end}) <= start ({start}); use the bare integer form for a single rate" + ); + } + let span = end - start; + if step > span { + bail!( + "range '{token}' has step ({step}) > end - start ({span}); step must fit in the range" + ); + } + if !span.is_multiple_of(step) { + bail!( + "range '{token}' has step ({step}) that does not evenly divide end - start ({span}); endpoint {end} would be silently dropped" + ); + } + let mut out = Vec::new(); + let mut v = start; + loop { + out.push(v); + let Some(next) = v.checked_add(step) else { + break; + }; + if next > end { + break; + } + v = next; + } + Ok(out) +} + +fn parse_rates(rates_str: &str) -> Result> { + let mut rates: Vec = Vec::new(); + for token in rates_str.split(',') { + rates.extend(parse_rate_token(token)?); + } + if rates.is_empty() { + bail!("--rates must not be empty"); + } + if rates.len() > MAX_RATES_EXPANDED { + bail!( + "--rates expanded to {} phases, capped at {MAX_RATES_EXPANDED}", + rates.len() + ); + } + if rates.windows(2).any(|w| w[0] >= w[1]) { + bail!("--rates must be strictly ascending, got: {rates_str}"); + } + Ok(rates) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_rates_accepts_comma_list() { + assert_eq!( + parse_rates("500,1000,2000,4000").unwrap(), + vec![500, 1000, 2000, 4000] + ); + } + + #[test] + fn parse_rates_accepts_range_with_step() { + assert_eq!( + parse_rates("1000-2000:100").unwrap(), + vec![1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000] + ); + } + + #[test] + fn parse_rates_mixes_singles_and_ranges() { + assert_eq!( + parse_rates("500,1000-1200:100,4000").unwrap(), + vec![500, 1000, 1100, 1200, 4000] + ); + } + + #[test] + fn parse_rates_rejects_step_not_dividing_range() { + let err = parse_rates("1000-2050:100").unwrap_err().to_string(); + assert!(err.contains("evenly divide"), "unexpected: {err}"); + } + + #[test] + fn parse_rates_rejects_start_equals_end() { + let err = parse_rates("1000-1000:100").unwrap_err().to_string(); + assert!(err.contains("bare integer"), "unexpected: {err}"); + } + + #[test] + fn parse_rates_rejects_step_larger_than_range() { + let err = parse_rates("1000-2000:5000").unwrap_err().to_string(); + assert!(err.contains("step must fit"), "unexpected: {err}"); + } + + #[test] + fn parse_rates_rejects_zero_rate() { + let err = parse_rates("0").unwrap_err().to_string(); + assert!(err.contains("must be > 0"), "unexpected: {err}"); + } + + #[test] + fn parse_rates_rejects_zero_start() { + let err = parse_rates("0-100:10").unwrap_err().to_string(); + assert!(err.contains("start = 0"), "unexpected: {err}"); + } + + #[test] + fn parse_rates_rejects_zero_step() { + let err = parse_rates("1000-2000:0").unwrap_err().to_string(); + assert!(err.contains("step = 0"), "unexpected: {err}"); + } + + #[test] + fn parse_rates_rejects_start_greater_than_end() { + let err = parse_rates("2000-1000:100").unwrap_err().to_string(); + // Folded into the "end <= start" guard along with start == end. + assert!( + err.contains("end") && err.contains("start"), + "unexpected: {err}" + ); + } + + #[test] + fn parse_rates_rejects_too_many_phases() { + // 1-300:1 would expand to 300 rates; cap is MAX_RATES_EXPANDED = 256. + let err = parse_rates("1-300:1").unwrap_err().to_string(); + assert!(err.contains("capped"), "unexpected: {err}"); + } + + #[test] + fn parse_rates_rejects_non_ascending_after_expansion() { + // 1500 falls inside the expanded range 1000..=2000, so the final + // sequence has a non-strict-ascending pair. + let err = parse_rates("1000-2000:100,1500").unwrap_err().to_string(); + assert!(err.contains("ascending"), "unexpected: {err}"); + } + + #[test] + fn parse_rates_rejects_malformed_range_missing_dash() { + let err = parse_rates("1000:100").unwrap_err().to_string(); + assert!(err.contains("START-END:STEP"), "unexpected: {err}"); + } + + fn phase( + offered: u64, + gas: Option, + tps: Option, + p95: Option, + mempool: Option, + ) -> PhaseRecord { + let now = Utc::now(); + PhaseRecord { + offered_tps: offered, + started_at: now, + load_ended_at: now, + ended_at: now, + metrics: PhaseMetrics { + gas_per_sec: gas, + actual_tps: tps, + max_mempool: mempool, + latency_p95_ms: p95, + ..Default::default() + }, + signals: vec![], + } + } + + // ── detect_saturation_signals ───────────────────────────────────────────── + + #[test] + fn first_phase_never_signals() { + let current = phase(500, Some(14_000_000.0), Some(450.0), Some(40.0), Some(0.0)); + assert!(detect_saturation_signals(None, ¤t).is_empty()); + } + + #[test] + fn healthy_growth_no_signals() { + let prev = phase(500, Some(14_000_000.0), Some(450.0), Some(40.0), Some(0.0)); + let current = phase(1000, Some(28_000_000.0), Some(900.0), Some(45.0), Some(0.0)); + let sigs = detect_saturation_signals(Some(&prev), ¤t); + assert!(sigs.is_empty(), "unexpected signals: {sigs:?}"); + } + + #[test] + fn gas_plateaued_fires_when_gas_barely_grows() { + let prev = phase(1000, Some(28_000_000.0), Some(900.0), Some(45.0), Some(0.0)); + // Gas grew by only 3% — below PLATEAU_THRESHOLD (10%) + let current = phase(2000, Some(28_840_000.0), Some(920.0), Some(50.0), Some(0.0)); + let sigs = detect_saturation_signals(Some(&prev), ¤t); + assert!( + sigs.iter() + .any(|s| matches!(s, SaturationSignal::GasPlateaued)), + "GasPlateaued expected but not found in {sigs:?}" + ); + } + + #[test] + fn tps_plateaued_fires_when_tps_barely_grows() { + let prev = phase(1000, Some(28_000_000.0), Some(900.0), Some(45.0), Some(0.0)); + // TPS grew by only 2% + let current = phase(2000, Some(31_000_000.0), Some(918.0), Some(50.0), Some(0.0)); + let sigs = detect_saturation_signals(Some(&prev), ¤t); + assert!( + sigs.iter() + .any(|s| matches!(s, SaturationSignal::TpsPlateaued)), + "TpsPlateaued expected but not found in {sigs:?}" + ); + } + + #[test] + fn tps_ratio_drop_fires_when_ratio_falls_sharply() { + // prev: 900 / 1000 = 0.90 ratio + let prev = phase(1000, Some(28_000_000.0), Some(900.0), Some(45.0), Some(0.0)); + // current: 850 / 4000 = 0.2125 ratio — well below 0.90 * (1 - 0.15) + let current = phase(4000, Some(30_000_000.0), Some(850.0), Some(60.0), Some(0.0)); + let sigs = detect_saturation_signals(Some(&prev), ¤t); + assert!( + sigs.iter() + .any(|s| matches!(s, SaturationSignal::TpsRatioDrop)), + "TpsRatioDrop expected but not found in {sigs:?}" + ); + } + + #[test] + fn latency_spike_fires_when_p95_doubles() { + let prev = phase( + 1000, + Some(28_000_000.0), + Some(900.0), + Some(100.0), + Some(0.0), + ); + // p95 went from 100ms to 250ms — more than 2× + let current = phase( + 2000, + Some(30_000_000.0), + Some(910.0), + Some(250.0), + Some(0.0), + ); + let sigs = detect_saturation_signals(Some(&prev), ¤t); + assert!( + sigs.iter() + .any(|s| matches!(s, SaturationSignal::LatencySpike)), + "LatencySpike expected but not found in {sigs:?}" + ); + } + + #[test] + fn latency_spike_does_not_fire_below_threshold() { + let prev = phase( + 1000, + Some(28_000_000.0), + Some(900.0), + Some(100.0), + Some(0.0), + ); + // p95 went from 100ms to 190ms — less than 2× + let current = phase( + 2000, + Some(30_000_000.0), + Some(910.0), + Some(190.0), + Some(0.0), + ); + let sigs = detect_saturation_signals(Some(&prev), ¤t); + assert!( + !sigs + .iter() + .any(|s| matches!(s, SaturationSignal::LatencySpike)), + "LatencySpike should not fire at 1.9× growth" + ); + } + + #[test] + fn mempool_growth_fires_when_pool_accumulates() { + let prev = phase( + 1000, + Some(28_000_000.0), + Some(900.0), + Some(45.0), + Some(50.0), + ); + // Grew by 1800 — above MEMPOOL_GROWTH_MIN (100) + let current = phase( + 2000, + Some(29_000_000.0), + Some(920.0), + Some(60.0), + Some(1850.0), + ); + let sigs = detect_saturation_signals(Some(&prev), ¤t); + assert!( + sigs.iter() + .any(|s| matches!(s, SaturationSignal::MempoolGrowth)), + "MempoolGrowth expected but not found in {sigs:?}" + ); + } + + #[test] + fn mempool_growth_does_not_fire_on_small_increase() { + let prev = phase( + 1000, + Some(28_000_000.0), + Some(900.0), + Some(45.0), + Some(50.0), + ); + // Grew by 80 — below MEMPOOL_GROWTH_MIN (100) + let current = phase( + 2000, + Some(30_000_000.0), + Some(950.0), + Some(48.0), + Some(130.0), + ); + let sigs = detect_saturation_signals(Some(&prev), ¤t); + assert!( + !sigs + .iter() + .any(|s| matches!(s, SaturationSignal::MempoolGrowth)), + "MempoolGrowth should not fire on small increase" + ); + } + + #[test] + fn multiple_signals_fire_at_saturation() { + let prev = phase( + 1000, + Some(28_000_000.0), + Some(900.0), + Some(60.0), + Some(10.0), + ); + let current = phase( + 2000, + Some(28_500_000.0), + Some(905.0), + Some(250.0), + Some(5000.0), + ); + let sigs = detect_saturation_signals(Some(&prev), ¤t); + let kinds: Vec<_> = sigs.iter().map(signal_abbrev).collect(); + assert!( + sigs.len() >= 3, + "expected ≥3 signals at saturation, got: {kinds:?}" + ); + } + + // ── compute_latency_stats ─────────────────────────────────────────── + + #[test] + fn latency_empty_returns_none() { + assert_eq!(compute_latency_stats(&[]), (None, None, None, None)); + } + + #[test] + fn latency_single_record() { + // Sample stddev needs n≥2, so for a single value it's None. + assert_eq!( + compute_latency_stats(&[150.0]), + (Some(150.0), None, Some(150.0), Some(150.0)) + ); + } + + #[test] + fn latency_stats_correct() { + // 10 values: 10, 20, ..., 100 ms + let latencies: Vec = (1..=10u64).map(|i| (i * 10) as f64).collect(); + let (avg, stddev, p50, p95) = compute_latency_stats(&latencies); + // mean = (10+20+...+100)/10 = 55 + assert_eq!(avg, Some(55.0)); + // sample stddev: variance = sum((x-55)^2)/(10-1) = 825/9 ≈ 91.6667 → sqrt ≈ 9.574 * sqrt(...) + // Computed directly: sqrt(8250/9) = sqrt(916.67) ≈ 30.276 + let sd = stddev.unwrap(); + assert!((sd - 30.276503).abs() < 1e-3, "stddev was {sd}"); + // p50 index = round(0.5 * 9) = round(4.5) = 5 → sorted[5] = 60 + assert_eq!(p50, Some(60.0)); + // p95 index = round(0.95 * 9) = round(8.55) = 9 → sorted[9] = 100 + assert_eq!(p95, Some(100.0)); + } + + #[test] + fn latency_sorted_before_percentile() { + let latencies = vec![300.0, 100.0, 200.0]; + let (_, _, p50, _) = compute_latency_stats(&latencies); + assert_eq!(p50, Some(200.0)); + } +} diff --git a/crates/quake/src/setup.rs b/crates/quake/src/setup.rs index 3493738d..545d9820 100644 --- a/crates/quake/src/setup.rs +++ b/crates/quake/src/setup.rs @@ -16,15 +16,9 @@ #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -use std::time::Duration; use std::{fs, path::Path}; use alloy_primitives::{address, Address}; -use arc_consensus_types::{ - Config, LoggingConfig, MetricsConfig, PruningConfig, RemoteSigningConfig, RpcConfig, - RuntimeConfig, SigningConfig, -}; -use arc_node_consensus::hardcoded_config; use arc_node_consensus_cli::args::Args; use arc_node_consensus_cli::cmd::start::{StartCmd, RUNTIME_SINGLE_THREADED}; use arc_node_consensus_cli::file::save_priv_validator_key; @@ -39,14 +33,14 @@ use serde::Serialize; use tracing::{debug, warn}; use url::Url; -use crate::cli_version::{apply_version_compat, supports_cli_flags}; +use crate::cli_version::apply_version_compat; use crate::infra::InfraType; use crate::manifest::{self, Subnets}; use crate::node::{CidrBlock, NodeMetadata, NodeName, SubnetName, RETH_HTTP_BASE_PORT}; use crate::nodekey::{self, NodekeyData}; use crate::nodes::NodesMetadata; use crate::testnet::QUAKE_DIR; -use crate::{shell, testnet, util}; +use crate::{shell, testnet}; const APP_CONSENSUS_DEFAULT_PORT: usize = 27000; const APP_METRICS_DEFAULT_PORT: usize = 29000; @@ -353,6 +347,10 @@ pub(crate) struct ComposeTemplateDataRemote { pub cl_cpu_limit: Option, /// Memory limit for the CL container in GiB; when None, the legacy default applies. pub cl_memory_limit_gb: Option, + /// Environment variables for the execution layer (Reth) container. + pub el_env: IndexMap, + /// Environment variables for the consensus layer (Malachite) container. + pub cl_env: IndexMap, } /// Generate docker compose content from the given template and data and write to the given path @@ -508,249 +506,10 @@ pub(crate) fn generate_app_private_keys( Ok(()) } -////////////////////////////////////////////////////////////////// -// TODO: Remove once the network is fully migrated to use CLI flags. I.e when -// all scenarios use v0.5.0 or later. -////////////////////////////////////////////////////////////////// - -/// Generate Malachite app config.toml files for each node. -/// -/// This is needed for backward compatibility with older versions that still require config.toml. -pub(crate) fn generate_app_config_files( - testnet_dir: &Path, - nodes_metadata: &NodesMetadata, - manifest: &manifest::Manifest, - force: bool, -) -> Result<()> { - debug!("Generating Malachite app configuration files..."); - - let num_nodes = nodes_metadata.num_nodes(); - - for (name, node) in manifest.nodes.iter().take(num_nodes) { - let node_home_dir = testnet_dir.join(name).join("malachite"); - let config_file = node_home_dir.join("config").join("config.toml"); - - // Skip if the configuration already exists - if !force && config_file.exists() { - debug!("⏭️ Skipping generating configuration for node {name}"); - continue; - } - - // Only generate config.toml for Legacy nodes (< v0.5.0). - // Modern nodes use CLI flags exclusively. - let legacy_config = match &node.cl_config { - manifest::NodeClConfig::Legacy(config) => config, - manifest::NodeClConfig::Modern(_) => continue, - }; - - debug!(node=%name, dir=%node_home_dir.display(), "Generating node configuration..."); - - let peers_ips: Vec = if let Some(peers) = &node.cl_persistent_peers { - nodes_metadata.resolve_cl_persistent_peers_list_ips(name, peers)? - } else { - nodes_metadata.default_cl_persistent_peers_list_ips(name) - }; - - // Generate an initial config and merge it with the config customisations from the manifest by ser/deserializing to TOML values - let initial_config = - generate_legacy_consensus_config(name, node, legacy_config, &peers_ips)?; - let config = util::merge_toml_values( - toml::Value::try_from(initial_config)?, - toml::Value::try_from(legacy_config.clone())?, - )? - .try_into()?; - - // Save config file - let args = Args { - home: Some(node_home_dir), - ..Args::default() - }; - save_config(&args.get_config_file_path()?, &config)?; - } - - debug!(dir=%testnet_dir.display(), "✅ Generated Consensus Layer configuration files"); - Ok(()) -} - -/// Generate a consensus configuration for a legacy (< v0.5.0) node. -fn generate_legacy_consensus_config( - name: &str, - node: &manifest::Node, - cl_config: &Config, - peers_ips: &[String], -) -> Result { - let transport = TransportProtocol::default(); - - // We use IPADDR_ANY to allow nodes to listen on all subnet interfaces. - let listen_ip = "0.0.0.0"; - - let p2p_listen_addr = transport.multiaddr(listen_ip, APP_CONSENSUS_DEFAULT_PORT); - - let cl_persistent_peers = peers_ips - .iter() - .map(|url| transport.multiaddr(url, APP_CONSENSUS_DEFAULT_PORT)) - .collect(); - - let metrics_listen_addr = format!("{listen_ip}:{APP_METRICS_DEFAULT_PORT}") - .parse() - .context("failed to parse metrics listen address")?; - - let persistent_peers_only = node.cl_persistent_peers_only; - - let gossipsub_overrides = hardcoded_config::GossipSubOverrides { - explicit_peering: node.cl_gossipsub.explicit_peering, - mesh_prioritization: node.cl_gossipsub.mesh_prioritization, - load: hardcoded_config::GossipLoad::from_str_opt(node.cl_gossipsub.load.as_deref()), - }; - - let discovery = &cl_config.consensus.p2p.discovery; - let discovery_enabled = discovery.enabled; - let num_outbound_peers = if discovery.num_outbound_peers > 0 { - discovery.num_outbound_peers - } else { - 20 - }; - let num_inbound_peers = if discovery.num_inbound_peers > 0 { - discovery.num_inbound_peers - } else { - 20 - }; - - let consensus = hardcoded_config::build_consensus_config( - p2p_listen_addr, - cl_persistent_peers, - persistent_peers_only, - discovery_enabled, - num_outbound_peers, - num_inbound_peers, - true, - gossipsub_overrides, - ); - - let value_sync = hardcoded_config::build_value_sync_config(true); - - let config = Config { - moniker: name.to_string(), - consensus, - value_sync, - metrics: MetricsConfig { - enabled: true, - listen_addr: metrics_listen_addr, - }, - logging: LoggingConfig::default(), - // Use single-threaded runtime for lower resource usage when running local devnet - runtime: RuntimeConfig::SingleThreaded, - prune: PruningConfig::default(), - execution: Default::default(), - rpc: RpcConfig { - enabled: true, - // IPADDR_ANY because we need external access to it for testing. - listen_addr: format!("0.0.0.0:{APP_RPC_DEFAULT_PORT}") - .parse() - .context("failed to parse RPC listen address")?, - }, - signing: if node.remote_signer.is_some() { - SigningConfig::Remote(RemoteSigningConfig { - endpoint: format!("http://{name}-signer-proxy:{REMOTE_SIGNER_PROXY_PORT}"), - timeout: Duration::from_secs(30), - ..Default::default() - }) - } else { - SigningConfig::Local - }, - }; - - Ok(config) -} - -/// Save config to a TOML file. -fn save_config(path: &Path, config: &Config) -> Result<()> { - if let Some(parent_dir) = path.parent() { - fs::create_dir_all(parent_dir) - .with_context(|| format!("Failed to create directory: {}", parent_dir.display()))?; - } - - // Serialize config to TOML string, then parse back to Value for manipulation - let toml_str = toml::to_string_pretty(config) - .with_context(|| format!("Failed to serialize config for {}", path.display()))?; - - let mut config_value = toml::from_str::(&toml_str) - .with_context(|| format!("Failed to parse config TOML for {}", path.display()))?; - - // Translate [prune] field names for backward compatibility with pre-v0.5.0 nodes, - // which read `block_interval` / `min_height` instead of - // `certificates_distance` / `certificates_before`. - if let Some(prune) = config_value - .as_table_mut() - .and_then(|t| t.get_mut("prune")) - .and_then(|v| v.as_table_mut()) - { - let distance = prune - .remove("certificates_distance") - .unwrap_or(toml::Value::Integer(0)); - let before = prune - .remove("certificates_before") - .unwrap_or(toml::Value::Integer(0)); - prune.insert("block_interval".to_string(), distance); - prune.insert("min_height".to_string(), before); - } - - // Add timeout fields to the [consensus] section for backward compatibility - if let Some(consensus) = config_value - .as_table_mut() - .and_then(|t| t.get_mut("consensus")) - .and_then(|v| v.as_table_mut()) - { - consensus.insert( - "timeout_propose".to_string(), - toml::Value::String("3s".to_string()), - ); - consensus.insert( - "timeout_propose_delta".to_string(), - toml::Value::String("500ms".to_string()), - ); - consensus.insert( - "timeout_prevote".to_string(), - toml::Value::String("1s".to_string()), - ); - consensus.insert( - "timeout_prevote_delta".to_string(), - toml::Value::String("500ms".to_string()), - ); - consensus.insert( - "timeout_precommit".to_string(), - toml::Value::String("1s".to_string()), - ); - consensus.insert( - "timeout_precommit_delta".to_string(), - toml::Value::String("500ms".to_string()), - ); - consensus.insert( - "timeout_rebroadcast".to_string(), - toml::Value::String("3s".to_string()), - ); - } - - // Convert back to string and write - let final_toml_str = toml::to_string_pretty(&config_value) - .with_context(|| format!("Failed to serialize final config for {}", path.display()))?; - - fs::write(path, final_toml_str) - .with_context(|| format!("Failed to write config file: {}", path.display()))?; - - Ok(()) -} - -////////////////////////////////////////////////////////////////// -// END OF TODO: Remove once the network is fully migrated to use CLI flags. I.e -// when all scenarios use v0.5.0 or later. -////////////////////////////////////////////////////////////////// - /// Generate CLI flags for a node based on its configuration. /// -/// For `NodeClConfig::Modern`: builds a `StartCmd` from the manifest config + -/// Node-level overrides + deployment-specific fields, then calls `to_cli_flags()`. -/// For `NodeClConfig::Legacy`: returns an empty Vec (uses config.toml instead). +/// Builds a `StartCmd` from the manifest config + Node-level overrides + +/// deployment-specific fields, then calls `to_cli_flags()`. /// /// `follow_endpoint_urls` are pre-resolved container-accessible EL RPC URLs for follow /// mode (e.g. `http://validator-1_el:8545` for local, `http://10.0.0.5:8545` for remote). @@ -766,102 +525,89 @@ pub(crate) fn generate_consensus_cli_flags( return generate_default_consensus_cli_flags(name, listen_ip, peers_ips, image_tag); }; - match &node.cl_config { - manifest::NodeClConfig::Legacy(_) => { - debug!("Skipping CLI flags for legacy CL node {name}"); - Ok(Vec::new()) - } - manifest::NodeClConfig::Modern(start_cmd) => { - let transport = TransportProtocol::default(); - - let mut cmd = start_cmd.clone(); + let transport = TransportProtocol::default(); - cmd.moniker = Some(name.to_string()); - cmd.p2p_addr = transport.multiaddr(listen_ip, APP_CONSENSUS_DEFAULT_PORT); - cmd.metrics = Some( - format!("{listen_ip}:{APP_METRICS_DEFAULT_PORT}") - .parse() - .context("failed to parse metrics listen address")?, - ); - cmd.rpc_addr = Some( - format!("0.0.0.0:{APP_RPC_DEFAULT_PORT}") - .parse() - .context("failed to parse RPC listen address")?, - ); + let mut cmd = node.cl_config.clone(); - if !peers_ips.is_empty() { - cmd.p2p_persistent_peers = peers_ips - .iter() - .map(|ip| transport.multiaddr(ip, APP_CONSENSUS_DEFAULT_PORT)) - .collect(); - } + cmd.moniker = Some(name.to_string()); + cmd.p2p_addr = transport.multiaddr(listen_ip, APP_CONSENSUS_DEFAULT_PORT); + cmd.metrics = Some( + format!("{listen_ip}:{APP_METRICS_DEFAULT_PORT}") + .parse() + .context("failed to parse metrics listen address")?, + ); + cmd.rpc_addr = Some( + format!("0.0.0.0:{APP_RPC_DEFAULT_PORT}") + .parse() + .context("failed to parse RPC listen address")?, + ); - cmd.p2p_persistent_peers_only = node.cl_persistent_peers_only; - cmd.gossipsub_explicit_peering = node.cl_gossipsub.explicit_peering; - cmd.gossipsub_mesh_prioritization = node.cl_gossipsub.mesh_prioritization; - cmd.gossipsub_load = node.cl_gossipsub.load.clone(); + if !peers_ips.is_empty() { + cmd.p2p_persistent_peers = peers_ips + .iter() + .map(|ip| transport.multiaddr(ip, APP_CONSENSUS_DEFAULT_PORT)) + .collect(); + } - if node.node_type == manifest::NodeType::Validator { - cmd.validator = true; - } + cmd.p2p_persistent_peers_only = node.cl_persistent_peers_only; + cmd.gossipsub_explicit_peering = node.cl_gossipsub.explicit_peering; + cmd.gossipsub_mesh_prioritization = node.cl_gossipsub.mesh_prioritization; + cmd.gossipsub_load = node.cl_gossipsub.load.clone(); - // `--validator` requires a non-zero `--suggested-fee-recipient`. When - // validator scenarios omit `cl_suggested_fee_recipient`, fall back to - // QUAKE_DEFAULT_FEE_RECIPIENT, which is what localdev genesis expects - // when `ProtocolConfig.rewardBeneficiary = 0`. - let effective_fee_recipient = node.cl_suggested_fee_recipient.or_else(|| { - (node.node_type == manifest::NodeType::Validator) - .then_some(QUAKE_DEFAULT_FEE_RECIPIENT) - }); - if let Some(addr) = effective_fee_recipient { - cmd.suggested_fee_recipient = Some(addr.into()); - } + if node.node_type == manifest::NodeType::Validator { + cmd.validator = true; + } - if node.remote_signer.is_some() { - cmd.signing_remote = Some(format!( - "http://{name}-signer-proxy:{REMOTE_SIGNER_PROXY_PORT}" - )); - } + // `--validator` requires a non-zero `--suggested-fee-recipient`. When + // validator scenarios omit `cl_suggested_fee_recipient`, fall back to + // QUAKE_DEFAULT_FEE_RECIPIENT, which is what localdev genesis expects + // when `ProtocolConfig.rewardBeneficiary = 0`. + let effective_fee_recipient = node.cl_suggested_fee_recipient.or_else(|| { + (node.node_type == manifest::NodeType::Validator).then_some(QUAKE_DEFAULT_FEE_RECIPIENT) + }); + if let Some(addr) = effective_fee_recipient { + cmd.suggested_fee_recipient = Some(addr.into()); + } - if cmd.prune_certificates_distance == 0 && cmd.prune_certificates_before == 0 { - if let Some(preset) = node.cl_prune_preset { - match preset { - manifest::ClPruningPreset::Full => cmd.full = true, - manifest::ClPruningPreset::Minimal => cmd.minimal = true, - } - } - } + if node.remote_signer.is_some() { + cmd.signing_remote = Some(format!( + "http://{name}-signer-proxy:{REMOTE_SIGNER_PROXY_PORT}" + )); + } - if node.follow { - cmd.follow = true; - cmd.follow_endpoints = follow_endpoint_urls - .iter() - .map(|url| { - url.parse() - .context(format!("invalid follow endpoint URL: {url}")) - }) - .collect::>>()?; + if cmd.prune_certificates_distance == 0 && cmd.prune_certificates_before == 0 { + if let Some(preset) = node.cl_prune_preset { + match preset { + manifest::ClPruningPreset::Full => cmd.full = true, + manifest::ClPruningPreset::Minimal => cmd.minimal = true, } - - let flags = cmd.to_cli_flags(); - validate_generated_cl_flags(&flags)?; - Ok(apply_version_compat(flags, image_tag)) } } + + if node.follow { + cmd.follow = true; + cmd.follow_endpoints = follow_endpoint_urls + .iter() + .map(|url| { + url.parse() + .context(format!("invalid follow endpoint URL: {url}")) + }) + .collect::>>()?; + } + + let flags = cmd.to_cli_flags(); + validate_generated_cl_flags(&flags)?; + Ok(apply_version_compat(flags, image_tag)) } /// Generate default CLI flags when no node config is provided. -/// Used for nodes without manifest entries that use the modern CL. +/// Used for nodes without manifest entries. fn generate_default_consensus_cli_flags( name: &str, listen_ip: &str, peers_ips: &[String], image_tag: Option<&str>, ) -> Result> { - if !supports_cli_flags(image_tag) { - return Ok(Vec::new()); - } - let transport = TransportProtocol::default(); let cmd = StartCmd { moniker: Some(name.to_string()), @@ -977,9 +723,51 @@ mod helpers { } }); + // Escape a string for safe inclusion inside a YAML double-quoted scalar. + // Required for CLI flag values that may contain `"` (e.g. --byzantine=). + // Handles `\`, `"`, and every C0 control char + DEL — a literal newline + // in a YAML scalar would either break the document or silently swallow + // the rest of the line. Callers (env vars, CLI flags) should normally + // be passing single-line scalars; this is defense-in-depth. + pub(super) fn yaml_dq_escape_str(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\0' => out.push_str("\\0"), + c if c.is_control() => { + let n = c as u32; + if n <= 0xFF { + out.push_str(&format!("\\x{n:02X}")); + } else if n <= 0xFFFF { + out.push_str(&format!("\\u{n:04X}")); + } else { + out.push_str(&format!("\\U{n:08X}")); + } + } + _ => out.push(c), + } + } + out + } + handlebars_helper!(yaml_dq_escape: |s: str| yaml_dq_escape_str(s)); + + // Whether `map` is an object containing `key`. Unlike `lookup`, this tests key + // presence rather than value truthiness, so an explicit empty-string value still + // counts as present (used to suppress a built-in env var the user overrides). + handlebars_helper!(contains_key: |map: Json, key: str| { + map.as_object().is_some_and(|m| m.contains_key(key)) + }); + pub fn register(handlebars: &mut Handlebars) { handlebars.register_helper("inc", Box::new(inc)); handlebars.register_helper("default", Box::new(default)); + handlebars.register_helper("yaml_dq_escape", Box::new(yaml_dq_escape)); + handlebars.register_helper("contains_key", Box::new(contains_key)); } } @@ -1204,7 +992,13 @@ mod tests { } let infra_data = InfraData::new_local("testnet".to_string(), &manifest_nodes); let manifest = Manifest::new(Some("testnet".to_string()), &manifest_nodes, &node_subnets); - NodesMetadata::new(infra_data, &manifest, &BTreeSet::new()).unwrap() + NodesMetadata::new( + infra_data, + &manifest, + &manifest.images.to_local().unwrap(), + &BTreeSet::new(), + ) + .unwrap() } fn create_test_nodekeys(num_nodes: usize) -> (IndexMap, NodesMetadata) { @@ -1215,12 +1009,121 @@ mod tests { (nodekeys, nodes_metadata) } - fn legacy_node(peers: Option>) -> manifest::Node { - manifest::Node { - cl_config: manifest::NodeClConfig::Legacy(Config::default()), - cl_persistent_peers: peers, - ..Default::default() - } + /// Render the remote node compose template with the given per-layer env maps. + fn render_remote_compose_env(el_env: serde_json::Value, cl_env: serde_json::Value) -> String { + let mut handlebars = handlebars::Handlebars::new(); + handlebars + .register_template_string( + "compose", + include_str!("../templates/remote/compose-node.yaml.hbs"), + ) + .unwrap(); + helpers::register(&mut handlebars); + + let ctx = serde_json::json!({ + "el_container_name": "node_el", + "cl_container_name": "node_cl", + "el_env": el_env, + "cl_env": cl_env, + }); + handlebars.render("compose", &ctx).unwrap() + } + + /// A per-node env var that collides with a built-in replaces it (no duplicate + /// YAML key), while non-colliding vars are added alongside the built-ins. + #[test] + fn test_compose_env_override_suppresses_builtin() { + let rendered = render_remote_compose_env( + serde_json::json!({ "PATH": "/custom/path", "FEATURE_X": "on" }), + serde_json::json!({ "ARC_GENESIS_FILE_PATH": "/custom/genesis.json", "CL_CUSTOM": "1" }), + ); + + // EL: PATH override replaces the built-in default; new var is added. + assert!(rendered.contains(r#"PATH: "/custom/path""#)); + assert!(!rendered.contains("PATH: /usr/local/sbin")); + assert!(rendered.contains(r#"FEATURE_X: "on""#)); + + // CL: ARC_GENESIS_FILE_PATH override replaces the built-in default. + assert!(rendered.contains(r#"ARC_GENESIS_FILE_PATH: "/custom/genesis.json""#)); + assert!(!rendered.contains("ARC_GENESIS_FILE_PATH: /assets/genesis.json")); + assert!(rendered.contains(r#"CL_CUSTOM: "1""#)); + } + + /// Env vars that do not collide with built-ins leave the built-ins in place. + #[test] + fn test_compose_env_keeps_builtins_when_no_collision() { + let rendered = + render_remote_compose_env(serde_json::json!({ "FOO": "bar" }), serde_json::json!({})); + + assert!(rendered.contains("PATH: /usr/local/sbin")); + assert!(rendered.contains("ARC_GENESIS_FILE_PATH: /assets/genesis.json")); + assert!(rendered.contains(r#"FOO: "bar""#)); + } + + /// Env values are emitted inside a double-quoted YAML scalar with `"` escaped. + #[test] + fn test_compose_env_value_is_yaml_escaped() { + let rendered = render_remote_compose_env( + serde_json::json!({ "JSON_CFG": r#"{"a":1}"# }), + serde_json::json!({}), + ); + + assert!(rendered.contains(r#"JSON_CFG: "{\"a\":1}""#)); + } + + /// `yaml_dq_escape` escapes every C0 control character so a stray newline + /// can't break the rendered compose YAML. `env_table_to_map` rejects these + /// at manifest-load time, but the helper is also used for CLI flags, so it + /// must defend itself too. + #[test] + fn test_yaml_dq_escape_handles_control_chars() { + let escaped = helpers::yaml_dq_escape_str("line1\nline2\tcol\rend\0\x07"); + assert_eq!(escaped, r#"line1\nline2\tcol\rend\0\x07"#); + // Backslash and quote still escape correctly when mixed with controls. + assert_eq!(helpers::yaml_dq_escape_str("a\\b\"c\n"), r#"a\\b\"c\n"#); + } + + /// An explicit empty-string override still counts as "present" and suppresses the + /// built-in, so no duplicate YAML key is emitted (key-presence, not truthiness). + #[test] + fn test_compose_env_empty_string_override_suppresses_builtin() { + let rendered = + render_remote_compose_env(serde_json::json!({ "PATH": "" }), serde_json::json!({})); + + assert!(!rendered.contains("PATH: /usr/local/sbin")); + assert!(rendered.contains(r#"PATH: """#)); + } + + /// The local template resolves `el_env`/`cl_env` per node inside `{{#each nodes}}`; + /// overrides still replace the matching built-in for that node's containers. + #[test] + fn test_local_compose_env_per_node_override() { + let mut handlebars = handlebars::Handlebars::new(); + handlebars + .register_template_string( + "compose", + include_str!("../templates/local/compose.yaml.hbs"), + ) + .unwrap(); + helpers::register(&mut handlebars); + + let ctx = serde_json::json!({ + "nodes": [{ + "el_env": { "RUST_LOG": "trace", "FEATURE_X": "on" }, + "cl_env": { "ARC_HALT_AT_BLOCK_HEIGHT": "5", "CL_CUSTOM": "1" }, + }], + }); + let rendered = handlebars.render("compose", &ctx).unwrap(); + + // EL: RUST_LOG override replaces the built-in default; new var added. + assert!(rendered.contains(r#"RUST_LOG: "trace""#)); + assert!(!rendered.contains("RUST_LOG: debug,net::peers=trace")); + assert!(rendered.contains(r#"FEATURE_X: "on""#)); + + // CL: ARC_HALT_AT_BLOCK_HEIGHT override replaces the built-in default. + assert!(rendered.contains(r#"ARC_HALT_AT_BLOCK_HEIGHT: "5""#)); + assert!(!rendered.contains("ARC_HALT_AT_BLOCK_HEIGHT: 0")); + assert!(rendered.contains(r#"CL_CUSTOM: "1""#)); } fn assert_peer_count( @@ -1386,92 +1289,6 @@ mod tests { ); } - #[test] - fn generate_legacy_consensus_config_uses_shared_subnet_peer_ips() { - let source = "source".to_string(); - let peer = "peer".to_string(); - let subnet_a = "subnet-a".to_string(); - let subnet_b = "subnet-b".to_string(); - - let mut manifest_nodes = IndexMap::new(); - manifest_nodes.insert(source.clone(), legacy_node(Some(vec![peer.clone()]))); - manifest_nodes.insert(peer.clone(), legacy_node(None)); - - let node_subnets = IndexMap::from([ - (source.clone(), vec![subnet_a.clone()]), - (peer.clone(), vec![subnet_a.clone(), subnet_b.clone()]), - ]); - let infra_data = InfraData::new_local("testnet".to_string(), &manifest_nodes); - let manifest = Manifest::new(Some("testnet".to_string()), &manifest_nodes, &node_subnets); - let nodes_metadata = NodesMetadata::new(infra_data, &manifest, &BTreeSet::new()).unwrap(); - let peer_metadata = nodes_metadata.get(&peer).unwrap(); - let shared_ip = peer_metadata - .consensus - .private_ip_address_for(&subnet_a) - .unwrap(); - let unshared_ip = peer_metadata - .consensus - .private_ip_address_for(&subnet_b) - .unwrap(); - - let peers_ips = nodes_metadata - .resolve_cl_persistent_peers_list_ips(&source, &[peer]) - .unwrap(); - let config = generate_legacy_consensus_config( - &source, - manifest_nodes.get(&source).unwrap(), - &Config::default(), - &peers_ips, - ) - .unwrap(); - let shared_peer = format!("/ip4/{shared_ip}/tcp/{APP_CONSENSUS_DEFAULT_PORT}"); - let persistent_peers = config - .consensus - .p2p - .persistent_peers - .iter() - .map(ToString::to_string) - .collect::>(); - - assert!( - persistent_peers.contains(&shared_peer), - "expected shared peer address in config: {persistent_peers:?}" - ); - assert!( - persistent_peers - .iter() - .all(|peer| !peer.contains(&unshared_ip)), - "unshared peer address should not be in config: {persistent_peers:?}" - ); - } - - #[test] - fn generate_app_config_files_errors_for_unreachable_legacy_peer() { - let dir = tempdir().unwrap(); - let source = "source".to_string(); - let peer = "peer".to_string(); - - let mut manifest_nodes = IndexMap::new(); - manifest_nodes.insert(source.clone(), legacy_node(Some(vec![peer.clone()]))); - manifest_nodes.insert(peer.clone(), legacy_node(None)); - - let node_subnets = IndexMap::from([ - (source.clone(), vec!["subnet-a".to_string()]), - (peer, vec!["subnet-b".to_string()]), - ]); - let infra_data = InfraData::new_local("testnet".to_string(), &manifest_nodes); - let manifest = Manifest::new(Some("testnet".to_string()), &manifest_nodes, &node_subnets); - let nodes_metadata = NodesMetadata::new(infra_data, &manifest, &BTreeSet::new()).unwrap(); - - let err = - generate_app_config_files(dir.path(), &nodes_metadata, &manifest, false).unwrap_err(); - - assert!( - err.to_string().contains("shares no subnet"), - "unexpected error: {err:?}" - ); - } - #[test] fn generate_consensus_cli_flags_includes_required_flags() { let flags = generate_consensus_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[]) @@ -1822,10 +1639,10 @@ mod tests { #[test] fn generate_consensus_cli_flags_includes_pruning_distance() { let node = manifest::Node { - cl_config: manifest::NodeClConfig::Modern(StartCmd { + cl_config: StartCmd { prune_certificates_distance: 500, ..StartCmd::default() - }), + }, ..Default::default() }; let flags = @@ -1845,10 +1662,10 @@ mod tests { #[test] fn generate_consensus_cli_flags_includes_pruning_before() { let node = manifest::Node { - cl_config: manifest::NodeClConfig::Modern(StartCmd { + cl_config: StartCmd { prune_certificates_before: 100, ..StartCmd::default() - }), + }, ..Default::default() }; let flags = @@ -1907,10 +1724,10 @@ mod tests { #[test] fn generate_consensus_cli_flags_prune_distance_emitted() { let node = manifest::Node { - cl_config: manifest::NodeClConfig::Modern(StartCmd { + cl_config: StartCmd { prune_certificates_distance: 500, ..StartCmd::default() - }), + }, ..Default::default() }; let flags = @@ -1923,21 +1740,6 @@ mod tests { ); } - #[test] - fn generate_consensus_cli_flags_returns_empty_for_old_version() { - let flags = generate_consensus_cli_flags( - "validator-1", - None, - "172.19.0.5", - &[], - Some("arc_consensus:v0.4.0"), - &[], - ) - .unwrap(); - - assert!(flags.is_empty()); - } - #[test] fn generate_consensus_cli_flags_returns_flags_for_new_version() { let flags = generate_consensus_cli_flags( @@ -2001,10 +1803,10 @@ mod tests { fn generate_consensus_cli_flags_includes_no_consensus() { let node = manifest::Node { node_type: manifest::NodeType::NonValidator, - cl_config: manifest::NodeClConfig::Modern(StartCmd { + cl_config: StartCmd { no_consensus: true, ..StartCmd::default() - }), + }, ..Default::default() }; let flags = @@ -2153,10 +1955,10 @@ mod tests { fn generate_consensus_cli_flags_prune_distance_overrides_preset() { let node = manifest::Node { cl_prune_preset: Some(manifest::ClPruningPreset::Minimal), - cl_config: manifest::NodeClConfig::Modern(StartCmd { + cl_config: StartCmd { prune_certificates_distance: 500, ..StartCmd::default() - }), + }, ..Default::default() }; let flags = @@ -2291,42 +2093,6 @@ mod tests { assert_eq!(original_contents, contents_after); } - #[test] - fn save_config_translates_prune_field_names_for_legacy_compat() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - - let config = Config { - prune: arc_consensus_types::PruningConfig { - certificates_distance: 237_600, - certificates_before: arc_consensus_types::Height::new(500), - }, - ..Default::default() - }; - - save_config(&path, &config).unwrap(); - - let contents = fs::read_to_string(&path).unwrap(); - // Legacy field names must be present - assert!( - contents.contains("block_interval = 237600"), - "expected block_interval in config: {contents}" - ); - assert!( - contents.contains("min_height = 500"), - "expected min_height in config: {contents}" - ); - // New field names must not appear - assert!( - !contents.contains("certificates_distance"), - "certificates_distance should be translated away: {contents}" - ); - assert!( - !contents.contains("certificates_before"), - "certificates_before should be translated away: {contents}" - ); - } - #[test] fn rewrite_rpc_forwarder_for_remote_rewrites_docker_hostname() { let node_subnets: IndexMap> = [ @@ -2339,7 +2105,13 @@ mod tests { manifest_nodes.insert("relay".to_string(), manifest::Node::default()); let infra_data = InfraData::new_local("testnet".to_string(), &manifest_nodes); let manifest = Manifest::new(Some("testnet".to_string()), &manifest_nodes, &node_subnets); - let nodes_metadata = NodesMetadata::new(infra_data, &manifest, &BTreeSet::new()).unwrap(); + let nodes_metadata = NodesMetadata::new( + infra_data, + &manifest, + &manifest.images.to_local().unwrap(), + &BTreeSet::new(), + ) + .unwrap(); let mut flags = vec!["--rpc.forwarder=http://relay_el:8545".to_string()]; rewrite_rpc_forwarder_for_remote(&mut flags, "arc", &nodes_metadata, &manifest.subnets); diff --git a/crates/quake/src/shell.rs b/crates/quake/src/shell.rs index 49bb3e3a..a80d3d8b 100644 --- a/crates/quake/src/shell.rs +++ b/crates/quake/src/shell.rs @@ -15,8 +15,11 @@ // limitations under the License. use color_eyre::eyre::{bail, eyre, Context, Result}; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use tracing::debug; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use tracing::{debug, info, warn}; /// Execute a command in a given directory /// @@ -98,6 +101,78 @@ pub(crate) fn exec_with_output(cmd: &str, args: Vec<&str>, dir: &Path) -> Result } } +/// Like `exec_with_output`, but streams each stdout/stderr line through the +/// runner's tracing infrastructure as it arrives, while still capturing the +/// full stdout for return. Use this when the wrapped subprocess is long-lived +/// (e.g. a remote spammer phase) and the user needs live visibility into what +/// it's doing rather than waiting for the buffered output at the end. +/// +/// `prefix` is prepended to each forwarded line so it's distinguishable from +/// the runner's own logs (e.g. `prefix="spammer"` produces `spammer | …`). +pub(crate) fn exec_with_streaming_output( + cmd: &str, + args: Vec<&str>, + dir: &Path, + prefix: &str, +) -> Result { + debug!(%cmd, args=%args.join(" "), dir=%dir.display(), "Executing (streaming)"); + + let mut child = std::process::Command::new(cmd) + .args(&args) + .current_dir(dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .wrap_err_with(|| format!("Failed to spawn {cmd} {}", args.join(" ")))?; + + let stdout = child.stdout.take().expect("stdout piped by spawn config"); + let stderr = child.stderr.take().expect("stderr piped by spawn config"); + + let captured_stdout = Arc::new(Mutex::new(String::new())); + let captured_stderr = Arc::new(Mutex::new(String::new())); + + let stdout_buf = Arc::clone(&captured_stdout); + let stdout_prefix = prefix.to_string(); + let stdout_thread = std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + info!("{stdout_prefix} | {line}"); + if let Ok(mut buf) = stdout_buf.lock() { + buf.push_str(&line); + buf.push('\n'); + } + } + }); + + let stderr_buf = Arc::clone(&captured_stderr); + let stderr_prefix = prefix.to_string(); + let stderr_thread = std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + warn!("{stderr_prefix} | {line}"); + if let Ok(mut buf) = stderr_buf.lock() { + buf.push_str(&line); + buf.push('\n'); + } + } + }); + + let status = child + .wait() + .wrap_err_with(|| format!("Failed to wait for {cmd} {}", args.join(" ")))?; + stdout_thread.join().expect("stdout reader thread panicked"); + stderr_thread.join().expect("stderr reader thread panicked"); + + let stdout_str = captured_stdout.lock().expect("stdout mutex").clone(); + let stderr_str = captured_stderr.lock().expect("stderr mutex").clone(); + + if status.success() { + Ok(stdout_str.trim().to_string()) + } else { + bail!("Command failed: {}", stderr_str.trim()); + } +} + /// Return the relative path to the given base path pub(crate) fn relative_path(path: &PathBuf, base: &PathBuf) -> Result { pathdiff::diff_paths(path, base) @@ -164,6 +239,25 @@ pub(crate) fn ssh_with_output( exec_with_output("bash", vec!["-c", full_cmd.as_str()], dir) } +/// Same as [`ssh_with_output`], but streams the remote command's stdout/stderr +/// line-by-line through the runner's tracing infrastructure as it arrives. Use +/// this for long-lived remote commands (e.g. a saturation phase's spammer +/// subprocess) where buffering the output until completion would hide what the +/// remote process is currently doing. +pub(crate) fn ssh_with_streaming_output( + host: &str, + user_name: &str, + private_key_path: &str, + dir: &Path, + cmd: &str, + prefix: &str, +) -> Result { + let ssh_opts = ssh_opts(host).join(" "); + let args = format!("{ssh_opts} -i {private_key_path} {user_name}@{host} \"{cmd}\""); + let full_cmd = format!("ssh {args}"); + exec_with_streaming_output("bash", vec!["-c", full_cmd.as_str()], dir, prefix) +} + /// Copy multiple files or directories to a remote server. /// /// Arguments: diff --git a/crates/quake/src/testnet.rs b/crates/quake/src/testnet.rs index b3692586..726d09e8 100644 --- a/crates/quake/src/testnet.rs +++ b/crates/quake/src/testnet.rs @@ -20,7 +20,7 @@ use std::time::Duration; use std::{env, fs}; use color_eyre::eyre::{self, bail, eyre, Context, Result}; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use itertools::Itertools; use rand::Rng; use tokio::task::JoinHandle; @@ -40,7 +40,8 @@ use crate::valset::ValidatorPowerUpdate; use crate::wait::{check_ws_connectable, wait_for_nodes, wait_for_nodes_sync, wait_for_rounds}; use crate::{build, clean, info as info_mod, latency, monitor, setup, shell}; use crate::{ - DownloadSubcommand, InfoSubcommand, MonitoringSubcommand, RemoteSubcommand, SSMSubcommand, + DownloadKindSubcommand, DownloadSubcommand, InfoSubcommand, MonitoringSubcommand, + RemoteSubcommand, SSMSubcommand, }; pub(crate) const QUAKE_DIR: &str = ".quake"; @@ -79,20 +80,6 @@ pub(crate) struct DockerImages { pub el_upgrade: Option, } -impl DockerImages { - /// All present image references (base + upgrade). - pub fn all(&self) -> Vec<&str> { - let mut out = vec![self.cl.as_str(), self.el.as_str()]; - if let Some(ref img) = self.cl_upgrade { - out.push(img.as_str()); - } - if let Some(ref img) = self.el_upgrade { - out.push(img.as_str()); - } - out - } -} - pub(crate) struct Testnet { pub name: String, pub dir: PathBuf, @@ -107,6 +94,21 @@ pub(crate) struct Testnet { pub nodes_metadata: NodesMetadata, } +/// Resolve an optional `--output` flag into a concrete archive path. If the +/// user passed a directory, append a timestamped `-.tar.gz` inside +/// it. If nothing was passed, use that timestamped name inside `default_dir`. +/// The directory is **not** created here — callers create it just-in-time +/// before writing, so a failed download does not leave an empty dir behind. +fn resolve_archive_path(output: Option, default_dir: &Path, prefix: &str) -> PathBuf { + let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S"); + let filename = format!("{prefix}-{ts}.tar.gz"); + match output { + None => default_dir.join(filename), + Some(p) if p.is_dir() => p.join(filename), + Some(p) => p, + } +} + impl Testnet { pub async fn from(manifest_file: &Option, force_remote: bool) -> Result { if let Some(manifest_file) = manifest_file { @@ -166,7 +168,7 @@ impl Testnet { // Build container information for the nodes in the manifest let nodes_metadata = - NodesMetadata::new(infra_data.clone(), &manifest, &upgraded_containers)?; + NodesMetadata::new(infra_data.clone(), &manifest, &images, &upgraded_containers)?; // Create infrastructure provider, monitoring manager (local only) let infra: Arc = match infra_data.infra_type { @@ -332,8 +334,14 @@ impl Testnet { InfraType::Local => { let local_infra = self.local_infra()?; - // The Docker images to build locally - let (reth_builds, malachite_builds) = build::local_images_to_build(&self.images); + // The Docker images to build locally, covering per-node overrides. + let (el_images, cl_images) = self.nodes_metadata.distinct_images(); + let (reth_builds, malachite_builds) = build::local_images_to_build( + &el_images, + &cl_images, + self.images.el_upgrade.as_ref(), + self.images.cl_upgrade.as_ref(), + ); // Generate Docker Compose files let compose_data = setup::ComposeTemplateDataLocal { @@ -435,13 +443,24 @@ impl Testnet { }) .collect(); - // Generate CL CLI flags including persistent peers + // Effective per-node images (mixed-version networks): the + // manifest override falls back to the global image. + let (cl, el) = node.effective_images(&self.images.cl, &self.images.el); + let node_images = DockerImages { + cl, + el, + cl_upgrade: None, + el_upgrade: None, + }; + + // Generate CL CLI flags including persistent peers. Pass this + // node's CL image so version-compat targets its own binary. let cl_cli_flags = setup::generate_consensus_cli_flags( node_name, Some(node), "0.0.0.0", // Remote nodes listen on all interfaces &peers_ips, - Some(self.images.cl.as_str()), + Some(node_images.cl.as_str()), &follow_endpoint_urls, )?; @@ -453,7 +472,7 @@ impl Testnet { latency_emulation: self.manifest.latency_emulation, rpc, remote_home_dir: format!("/home/{}", remote::USER_NAME), - images: self.images.clone(), + images: node_images, cl_cli_flags, el_cli_flags, trusted_peers: trusted_peers.get(node_name).cloned().unwrap_or_default(), @@ -461,6 +480,8 @@ impl Testnet { el_memory_limit_gb: self.manifest.el_memory_limit_gb, cl_cpu_limit: self.manifest.cl_cpu_limit, cl_memory_limit_gb: self.manifest.cl_memory_limit_gb, + el_env: node.el_env.clone(), + cl_env: node.cl_env.clone(), }; // Create node directory for compose file let node_dir = self.dir.join(node_name); @@ -483,9 +504,6 @@ impl Testnet { // Validators must get keys first so their BIP39 indices match the genesis. setup::generate_app_private_keys(&self.dir, &self.nodes_metadata, force)?; - // Generate Malachite app config files for each node (for backward compatibility with older versions) - setup::generate_app_config_files(&self.dir, &self.nodes_metadata, &self.manifest, force)?; - // Generate JWT secret for authenticated RPC connection between Reth and Malachite if rpc { setup::generate_jwt_secret(&self.dir, force)?; @@ -535,7 +553,7 @@ impl Testnet { // Pull remote images (local mode only) if self.is_local() { - let remote_images = build::remote_images_to_pull(&self.images); + let remote_images = build::remote_images_to_pull(&self.effective_images()); remote_images.iter().try_for_each(|image| { info!(%image, "Pulling remote image"); infra::docker::pull(image) @@ -633,7 +651,10 @@ impl Testnet { } // Start containers associated with the node group - let containers: Vec<_> = nodes.iter().flat_map(|n| n.container_names()).collect(); + let containers: Vec<_> = nodes + .iter() + .flat_map(|n| n.running_container_names()) + .collect(); debug!(containers=%containers.join(", "), "Starting containers"); self.infra.start(&containers)?; @@ -717,7 +738,7 @@ impl Testnet { /// Once connected, monitoring errors (including timeout) are returned immediately /// without trying other nodes. pub async fn wait_rounds(&self, consecutive: u64, timeout: Duration) -> Result<()> { - let consensus_urls = self.nodes_metadata.all_consensus_rpc_urls(); + let consensus_urls = self.nodes_metadata.all_consensus_enabled_rpc_urls(); if consensus_urls.is_empty() { bail!("No consensus nodes found"); } @@ -1074,6 +1095,9 @@ impl Testnet { let node_volume_iops = infra_args .node_volume_iops .or(self.manifest.node_volume_iops); + // Enabled by either the CLI flag or the manifest field. + let node_data_on_instance_store = infra_args.node_data_on_instance_store + || self.manifest.node_data_on_instance_store; // CLI overrides can mix freely with manifest fields, so re-validate // the merged pair before reaching Terraform. crate::manifest::validate_node_volume(node_volume_type, node_volume_iops)?; @@ -1086,6 +1110,7 @@ impl Testnet { cc_disk_gb, node_volume_type, node_volume_iops, + node_data_on_instance_store, ) } RemoteSubcommand::Status => { @@ -1113,6 +1138,9 @@ impl Testnet { SSMSubcommand::Start => infra.ssm_tunnels.start().await, SSMSubcommand::Stop => infra.ssm_tunnels.stop().await, SSMSubcommand::List => infra.ssm_tunnels.list().await, + SSMSubcommand::KeepAlive { duration } => { + infra.ssm_tunnels.keep_alive(duration).await + } }, RemoteSubcommand::Destroy { yes } => { if let Err(err) = infra.ssm_tunnels.stop().await { @@ -1157,15 +1185,9 @@ impl Testnet { // File import handled in main(); start SSM tunnels so quake commands work immediately RemoteSubcommand::Import { .. } => infra.ssm_tunnels.start().await, RemoteSubcommand::Download { command } => { - let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S"); - let resolve = |output: Option, prefix: &str| -> PathBuf { - let default = PathBuf::from(format!("{prefix}-{ts}.tar.gz")); - match output { - None => default, - Some(p) if p.is_dir() => p.join(default), - Some(p) => p, - } - }; + warn!( + "`quake remote download` is deprecated; use `quake download {{metrics,db}}` instead" + ); match command { DownloadSubcommand::Metrics { from, @@ -1174,24 +1196,15 @@ impl Testnet { metric_names, output, } => { - let dest = resolve(output, "quake-metrics"); - infra.download_metrics( - &metric_names, - from.map(|dt| dt.unix_secs()), - to.map(|dt| dt.unix_secs()), - step.as_deref(), - &dest, - ) + self.download_metrics(from, to, step, metric_names, output) + .await } DownloadSubcommand::Db { nodes, execution_only, consensus_only, output, - } => { - let dest = resolve(output, "quake-db"); - infra.download_node_db(&nodes, execution_only, consensus_only, &dest) - } + } => self.download_db(nodes, execution_only, consensus_only, output), } } } @@ -1210,6 +1223,104 @@ impl Testnet { } } + /// Download monitoring data (metrics and/or node databases). + pub async fn download(&self, command: Option) -> Result<()> { + match command { + Some(DownloadKindSubcommand::Metrics { + from, + to, + step, + metric_names, + output, + }) => { + self.download_metrics(from, to, step, metric_names, output) + .await + } + Some(DownloadKindSubcommand::Db { + nodes, + execution_only, + consensus_only, + output, + }) => self.download_db(nodes, execution_only, consensus_only, output), + None => { + self.download_metrics(None, None, None, vec![], None) + .await?; + self.download_db(vec![], false, false, None) + } + } + } + + async fn download_metrics( + &self, + from: Option, + to: Option, + step: Option, + metric_names: Vec, + output: Option, + ) -> Result<()> { + let default_dir = self.quake_dir.join("metrics").join(&self.name); + let dest = resolve_archive_path(output, &default_dir, "quake-metrics"); + let names: Vec<&str> = metric_names.iter().map(String::as_str).collect(); + let (prometheus_port, _, _) = self.infra_data.monitoring_ports(); + let prometheus_url = format!("http://127.0.0.1:{prometheus_port}"); + crate::metrics::download_to_tarball( + &prometheus_url, + &names, + from.map(|t| t.unix_secs()), + to.map(|t| t.unix_secs()), + step.as_deref(), + &dest, + ) + .await + } + + fn download_db( + &self, + nodes: Vec, + execution_only: bool, + consensus_only: bool, + output: Option, + ) -> Result<()> { + warn!( + "⚠️ Downloading node DBs while nodes are still running may yield an inconsistent snapshot. Stop nodes first (`quake stop`) if you need a consistent point-in-time view." + ); + + let target_nodes: Vec = if nodes.is_empty() { + let first = self + .manifest + .nodes + .keys() + .next() + .ok_or_else(|| eyre!("manifest has no nodes"))? + .clone(); + info!(node=%first, "No node specified; defaulting to the first node in the manifest"); + vec![first] + } else { + nodes + }; + + match self.infra_data.infra_type { + InfraType::Local => { + for node in &target_nodes { + let node_dir = self.dir.join(node); + if !consensus_only { + info!(node=%node, path=%node_dir.join("reth").display(), "📂 Local EL data"); + } + if !execution_only { + info!(node=%node, path=%node_dir.join("malachite").display(), "📂 Local CL data"); + } + } + Ok(()) + } + InfraType::Remote => { + let infra = self.remote_infra()?; + let default_dir = self.quake_dir.join("db").join(&self.name); + let dest = resolve_archive_path(output, &default_dir, "quake-db"); + infra.download_node_db(&target_nodes, execution_only, consensus_only, &dest) + } + } + } + pub(crate) async fn valset_update(&self, targets: Vec) -> Result<()> { let assets_dir = self.dir.join("assets"); let mut controllers = Controllers::load_from_file(&assets_dir)?; @@ -1305,6 +1416,15 @@ impl Testnet { }) .unwrap_or_default(); + // Per-node CL image so version-compat rewrites this node's flags to + // its own binary's schema, not a single global image (mixed networks). + let cl_image = self + .nodes_metadata + .nodes + .get(&name) + .map(|n| n.consensus.image.clone()) + .unwrap_or_default(); + // Local compose defines both the current CL service and the `_u` // upgrade service. Generate flags for each target image because // version compatibility can rewrite the two flag sets differently. @@ -1313,7 +1433,7 @@ impl Testnet { node_config, &listen_ip, &peers_ips, - Some(self.images.cl.as_str()), + Some(cl_image.as_str()), &follow_endpoint_urls, )?; @@ -1352,4 +1472,15 @@ impl Testnet { println!(" - Pprof proxy: http://localhost:{PPROF_PROXY_SSM_PORT}/nodes, http://localhost:{PPROF_PROXY_SSM_PORT}/health"); } } + + /// All distinct images the running network references: each node's effective + /// CL and EL image plus the global upgrade images. Used for pull and existence + /// checks so per-node overrides are covered, not just the global images. + pub(crate) fn effective_images(&self) -> Vec { + let (el, cl) = self.nodes_metadata.distinct_images(); + let mut seen: IndexSet = el.into_iter().chain(cl).collect(); + seen.extend(self.images.cl_upgrade.clone()); + seen.extend(self.images.el_upgrade.clone()); + seen.into_iter().collect() + } } diff --git a/crates/quake/src/tests/infra.rs b/crates/quake/src/tests/infra.rs new file mode 100644 index 00000000..c61f0e1b --- /dev/null +++ b/crates/quake/src/tests/infra.rs @@ -0,0 +1,287 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Infrastructure-level checks (substrate health, not consensus correctness). +//! +//! Tests in this group probe the underlying infrastructure (containers, hosts, +//! network rules) rather than the chain itself. They require shell access into +//! containers and are therefore slower than `probe:*` / `net:*` tests; the +//! group is excluded from bare `quake test` and must be invoked explicitly +//! (e.g. `quake test infra`). Intended use is operator pre-experiment +//! readiness on a running testnet. +//! +//! # Tests +//! +//! - `infra:latency_emulation` — verify `tc netem` rules inside each node's +//! CL and EL containers match the manifest. When `latency_emulation = true`, +//! cross-check each peer's expected delay against `AWS_LATENCY_MATRIX` +//! within ±10% tolerance. When `latency_emulation = false`, assert no +//! `netem` qdiscs exist (catches stale rules from a prior latency run). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use color_eyre::eyre::{eyre, Result}; +use tokio::sync::Semaphore; +use tokio::task::JoinSet; +use tracing::debug; + +use super::{quake_test, CheckResult, RpcClientFactory, TestOutcome, TestParams, TestResult}; +use crate::infra::exec::ExecBackend; +use crate::latency; +use crate::node::{NodeName, CONSENSUS_SUFFIX, EXECUTION_SUFFIX}; +use crate::testnet::Testnet; + +/// Delay match tolerance: actual must be within ±10% of expected. +const DELAY_TOLERANCE: f64 = 0.10; + +/// Container interface that carries inter-node traffic. The latency setup +/// script enumerates all `^(eth|ens|eno|enp)` interfaces, but every Quake +/// container today exposes its routed subnet on `eth0`; multi-interface +/// bridge nodes are not handled in this v1 check. +const PROBE_INTERFACE: &str = "eth0"; + +/// Cap on concurrent `tc` probes. Remote probes go through the Control +/// Center's sshd; the default `MaxStartups 10:30:100` starts dropping +/// connections above ~10 in-flight, so we stay comfortably under it. +const PROBE_CONCURRENCY: usize = 8; + +const CONTAINER_SUFFIXES: [&str; 2] = [CONSENSUS_SUFFIX, EXECUTION_SUFFIX]; + +type ContainerName = &'static str; +type ProbeResult = ((NodeName, ContainerName), Result); +type IpAddressHex = String; +type ExpectedDelays = HashMap; + +#[quake_test(group = "infra", name = "latency_emulation")] +fn latency_emulation_test<'a>( + testnet: &'a Testnet, + _factory: &'a RpcClientFactory, + _params: &'a TestParams, +) -> TestResult<'a> { + Box::pin(async move { + let enabled = testnet.manifest.latency_emulation; + debug!(enabled, "Probing latency emulation rules"); + let backend = ExecBackend::from(testnet); + let nodes = testnet.nodes_metadata.node_names(); + if enabled { + let results = probe_all(backend, nodes, true).await; + let expected = latency::build_expected_delays(&testnet.dir, &testnet.nodes_metadata)?; + assert_tc_rules_match_matrix(results, expected).await + } else { + let results = probe_all(backend, nodes, false).await; + assert_no_tc_rules(results).await + } + }) +} + +async fn assert_tc_rules_match_matrix( + results: Vec, + expected: HashMap, +) -> Result<()> { + let mut outcome = TestOutcome::new(); + for ((node, suffix), result) in results { + let label = format!("{node}_{suffix}"); + let Some(expected) = expected.get(&node) else { + outcome.add_check(CheckResult::failure( + label, + format!("node '{node}' not in region_assignments.json"), + )); + continue; + }; + let compared = result.and_then(|outputs| { + let filter = outputs + .filter + .as_deref() + .ok_or_else(|| eyre!("missing filter output (internal bug)"))?; + compare_against_matrix(&outputs.qdisc, filter, expected) + }); + match compared { + Ok(summary) => outcome.add_check(CheckResult::success(label, summary)), + Err(e) => outcome.add_check(CheckResult::failure(label, e.to_string())), + } + } + + outcome + .auto_summary( + "All containers match the expected latency matrix", + "{} container(s) failed latency matrix check", + ) + .into_result() +} + +/// Pure: compare already-fetched `tc` output against the expected matrix. +fn compare_against_matrix(qdisc: &str, filter: &str, expected: &ExpectedDelays) -> Result { + let handle_to_delay = latency::parse_netem_qdiscs(qdisc); + let ip_to_handle = latency::parse_u32_filters(filter); + + if handle_to_delay.is_empty() { + if expected.is_empty() { + return Ok("no inter-region peers; no latency rules expected".to_string()); + } else { + return Err(eyre!("no netem qdiscs on {PROBE_INTERFACE}")); + }; + } + + let mut mismatched = Vec::new(); + let mut checked = 0_usize; + // Verify every expected peer has a filter routing it to a netem qdisc + // whose delay matches the matrix. + for (peer_hex, expected_delay) in expected { + let Some(handle) = ip_to_handle.get(peer_hex) else { + mismatched.push(format!("no filter for ip {peer_hex}")); + continue; + }; + let Some(actual_delay) = handle_to_delay.get(handle) else { + mismatched.push(format!("filter 1:{handle} has no netem qdisc")); + continue; + }; + let drift = (f64::from(*actual_delay) - f64::from(*expected_delay)).abs() + / f64::from(*expected_delay); + if drift > DELAY_TOLERANCE { + mismatched.push(format!( + "ip {peer_hex}: expected {expected_delay}ms, got {actual_delay}ms ({:.0}% drift)", + drift * 100.0 + )); + } + checked += 1; + } + + // Fail on stale rules left by a prior latency config: filters routing peers + // we don't expect to delay, and netem qdiscs no expected filter points at. + for ip in ip_to_handle.keys() { + if !expected.contains_key(ip) { + mismatched.push(format!("unexpected filter for ip {ip}")); + } + } + let expected_handles: HashSet<&str> = expected + .keys() + .filter_map(|ip| ip_to_handle.get(ip)) + .map(String::as_str) + .collect(); + for handle in handle_to_delay.keys() { + if !expected_handles.contains(handle.as_str()) { + mismatched.push(format!("unexpected netem qdisc 1:{handle}")); + } + } + + if mismatched.is_empty() { + Ok(format!( + "{checked} peer rule(s) within \u{00b1}{:.0}% of matrix", + DELAY_TOLERANCE * 100.0 + )) + } else { + Err(eyre!("{}", mismatched.join("; "))) + } +} + +async fn assert_no_tc_rules(results: Vec) -> Result<()> { + let mut outcome = TestOutcome::new(); + for ((node, suffix), result) in results { + let label = format!("{node}_{suffix}"); + match result { + Ok(outputs) => { + let netems = latency::parse_netem_qdiscs(&outputs.qdisc); + if netems.is_empty() { + outcome.add_check(CheckResult::success(label, "no netem qdiscs")); + } else { + outcome.add_check(CheckResult::failure( + label, + format!("{} stale netem qdisc(s): {:?}", netems.len(), netems), + )); + } + } + Err(e) => outcome.add_check(CheckResult::failure(label, e.to_string())), + } + } + + outcome + .auto_summary( + "No latency rules present (as expected by manifest)", + "{} container(s) have stale latency rules", + ) + .into_result() +} + +// ── Parallel probe fan-out ───────────────────────────────────────────── + +struct ProbeOutputs { + qdisc: String, + /// `None` iff `kind = QdiscOnly`. + filter: Option, +} + +/// Execute per-(node, container) `tc` probes in parallel, capped by +/// `PROBE_CONCURRENCY`. Results are returned with deterministic ordering +/// (sorted by node name, then container suffix) so output stays readable. +async fn probe_all(backend: ExecBackend, nodes: Vec, enabled: bool) -> Vec { + let semaphore = Arc::new(Semaphore::new(PROBE_CONCURRENCY)); + let mut set: JoinSet = JoinSet::new(); + + for node in nodes { + for container in CONTAINER_SUFFIXES { + let backend = backend.clone(); + let semaphore = semaphore.clone(); + let node = node.clone(); + set.spawn(async move { + let _permit = semaphore + .acquire_owned() + .await + .expect("semaphore never closed"); + let node_for_blocking = node.clone(); + let blocking = tokio::task::spawn_blocking(move || { + probe_one(&backend, &node_for_blocking, container, enabled) + }); + let result = match blocking.await { + Ok(res) => res, + Err(e) => Err(eyre!("probe task panicked: {e}")), + }; + ((node, container), result) + }); + } + } + + let mut out = Vec::new(); + while let Some(joined) = set.join_next().await { + match joined { + Ok(res) => out.push(res), + Err(e) => panic!("probe join failed: {e}"), + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +fn probe_one( + backend: &ExecBackend, + node: &NodeName, + container: &str, + enabled: bool, +) -> Result { + let node = node.to_string(); + + let args = ["tc", "qdisc", "show", "dev", PROBE_INTERFACE]; + let qdisc = backend.exec_in_container(&node, container, &args)?; + + let filter = if enabled { + let args = ["tc", "filter", "show", "dev", PROBE_INTERFACE]; + Some(backend.exec_in_container(&node, container, &args)?) + } else { + None + }; + + Ok(ProbeOutputs { qdisc, filter }) +} diff --git a/crates/quake/src/tests/mesh.rs b/crates/quake/src/tests/mesh.rs index 9685985e..e199c0d9 100644 --- a/crates/quake/src/tests/mesh.rs +++ b/crates/quake/src/tests/mesh.rs @@ -44,7 +44,7 @@ //! | `circle-validator` | Validator without `external = true` | `FullyConnected` (multi-hop ok if externals exist) | //! | `external-validator` | Validator with `external = true` | Not `NotConnected` | //! | `consensus-participant` | Non-validator, consensus enabled | Not `NotConnected` | -//! | `excluded` | `follow = true` or `consensus.enabled = false` | Skipped | +//! | `excluded` | `follow = true` or `no_consensus = true` | Skipped | //! //! The key insight is that external validators are **expected** to be //! `MultiHop` — they sit behind dedicated sentries and never have direct @@ -85,7 +85,7 @@ pub(crate) enum NodeCategory { /// Must not be `NotConnected` in strict mode. ConsensusParticipant, - /// Node with `consensus.enabled = false` or `follow = true`. + /// Node with `no_consensus = true` or `follow = true`. /// Skipped entirely — no mesh expectations. Excluded, } diff --git a/crates/quake/src/tests/mod.rs b/crates/quake/src/tests/mod.rs index 5e1d1ec0..c38e4805 100644 --- a/crates/quake/src/tests/mod.rs +++ b/crates/quake/src/tests/mod.rs @@ -75,7 +75,7 @@ //! Tests are executed via the `quake test` command with glob pattern support: //! //! ## Basic Usage -//! - `quake test` - Run all tests except excluded groups (`validation`, `health`, `validator_set`, `perf`) +//! - `quake test` - Run all tests except excluded groups (`validation`, `health`, `validator_set`, `perf`, `infra`) //! - `quake test probe` - Run all tests in the probe group //! - `quake test probe:connectivity` - Run a single test //! - `quake test probe:connectivity,sync` - Run multiple specific tests @@ -83,6 +83,7 @@ //! - `quake test health:stability` - Run excluded groups explicitly //! - `quake test validator_set:malformed_key_skipped` - Run excluded groups explicitly //! - `quake test perf:block_time` - Run excluded groups explicitly +//! - `quake test infra:latency_emulation` - Run excluded groups explicitly (pre-experiment readiness gate) //! - `quake test --dry-run` - List all available test groups and tests //! - `quake test probe --dry-run` - List tests in a specific group //! @@ -125,6 +126,7 @@ pub(crate) mod snapshot; // Test modules - must come after type definitions so they can use them pub(crate) mod arc_node; mod health; +mod infra; mod malformed_validator; mod mempool; pub(crate) mod mesh; @@ -135,6 +137,7 @@ mod probe; pub(crate) mod sanity; mod sync; mod tx; +mod tx_relay; /// List matched tests in a formatted way fn list_matched_tests(matched_tests: &std::collections::HashMap>) { @@ -244,19 +247,23 @@ pub(crate) async fn run_tests( // Match test specifications using glob patterns let mut matched_tests = match_test_specs(®istry, &group_pattern, test_patterns)?; - // Exclude flaky / strict / state-mutating groups from the default (empty spec) run. + // Exclude flaky / strict / state-mutating / substrate groups from the default (empty spec) run. // - `validation`: generates load that leaves pending txs, elevated metrics // - `health`: assertions (e.g. sync_fell_behind == 0) are too strict for CI // - `validator_set`: mutates persistent on-chain state (registers extra validators) // - `perf`: thresholds are too tight for shared CI runners; run via nightly-perf workflow + // - `infra`: shell-exec into every container; cost scales with node count, intended + // as an operator pre-experiment readiness gate rather than a CI signal // Run explicitly: `quake test validation:basic`, `quake test health:stability`, - // `quake test validator_set:malformed_key_skipped`, `quake test perf:block_time` + // `quake test validator_set:malformed_key_skipped`, `quake test perf:block_time`, + // `quake test infra:latency_emulation`. // NOTE: update module-level doc comments (Basic Usage / Glob Pattern) if changing exclusions. if spec.is_empty() { matched_tests.remove("validation"); matched_tests.remove("health"); matched_tests.remove("validator_set"); matched_tests.remove("perf"); + matched_tests.remove("infra"); } // If dry-run, just list the tests diff --git a/crates/quake/src/tests/net.rs b/crates/quake/src/tests/net.rs index 8d1ed5bc..3ad8d4f0 100644 --- a/crates/quake/src/tests/net.rs +++ b/crates/quake/src/tests/net.rs @@ -81,81 +81,16 @@ fn cl_persistent_peers_test<'a>( Box::pin(async move { debug!("Testing persistent peer connections..."); - let mut outcome = TestOutcome::new(); - - for (node_name, node_config) in testnet.manifest.nodes.iter() { - if let Some(persistent_peer_names) = &node_config.cl_persistent_peers { - if persistent_peer_names.is_empty() { - continue; - } - - // Get the node metadata - let node_metadata = match testnet.nodes_metadata.get(node_name) { - Some(metadata) => metadata, - None => { - outcome.add_check(CheckResult::failure( - node_name, - "Node metadata not found".to_string(), - )); - continue; - } - }; - - // Get the peers connected to this node - let client = factory.create(node_metadata.execution.http_url.clone()); - - let peers = match client.get_peers().await { - Ok(peers) => peers, - Err(e) => { - outcome.add_check(CheckResult::failure( - node_name, - format!("Error fetching peers: {}", e), - )); - continue; - } - }; - - // Check if all persistent peers are connected - let missing_peers: Vec<_> = persistent_peer_names - .iter() - .filter(|persistent_peer_name| { - // Check if this peer name appears in any connected peer - !peers.iter().any(|peer| { - // Match by checking if the persistent peer name is in the enode - testnet - .nodes_metadata - .get(persistent_peer_name) - .map(|peer_meta| { - if let Ok(url) = reqwest::Url::parse(&peer.enode) { - if let Some(host) = url.host_str() { - // Check if the host is in the private IPs of the peer - let ips = peer_meta.execution.private_ip_addresses(); - return ips.contains(&host.to_string()); - } - } - false - }) - .unwrap_or(false) - }) - }) - .cloned() - .collect(); - - if missing_peers.is_empty() { - outcome.add_check(CheckResult::success( - node_name, - format!( - "All {} persistent peers connected", - persistent_peer_names.len() - ), - )); - } else { - outcome.add_check(CheckResult::failure( - node_name, - format!("Missing persistent peers: {}", missing_peers.join(", ")), - )); - } - } + // Persistent peers can take several seconds to (re)dial each other after + // startup — especially a validator that starts late (h100 manifests) and + // must rejoin the mesh — so poll until every declared connection is up + // rather than failing on a single snapshot. + let deadline = tokio::time::Instant::now() + PERSISTENT_PEERS_TIMEOUT; + let mut outcome = evaluate_persistent_peers(testnet, factory).await; + while !outcome.is_success() && tokio::time::Instant::now() < deadline { + tokio::time::sleep(PERSISTENT_PEERS_POLL_INTERVAL).await; + debug!("Persistent peers not yet fully connected, retrying..."); + outcome = evaluate_persistent_peers(testnet, factory).await; } if outcome.checks.is_empty() { @@ -171,3 +106,94 @@ fn cl_persistent_peers_test<'a>( .into_result() }) } + +/// Maximum time to wait for all configured persistent-peer connections to +/// establish before failing the check. +const PERSISTENT_PEERS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Delay between persistent-peer connectivity polls. +const PERSISTENT_PEERS_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); + +/// Take one snapshot of persistent-peer connectivity: for every node that +/// declares persistent peers, verify each declared peer is currently connected +/// (present in the node's `admin_peers` list). +async fn evaluate_persistent_peers(testnet: &Testnet, factory: &RpcClientFactory) -> TestOutcome { + let mut outcome = TestOutcome::new(); + + for (node_name, node_config) in testnet.manifest.nodes.iter() { + if let Some(persistent_peer_names) = &node_config.cl_persistent_peers { + if persistent_peer_names.is_empty() { + continue; + } + + // Get the node metadata + let node_metadata = match testnet.nodes_metadata.get(node_name) { + Some(metadata) => metadata, + None => { + outcome.add_check(CheckResult::failure( + node_name, + "Node metadata not found".to_string(), + )); + continue; + } + }; + + // Get the peers connected to this node + let client = factory.create(node_metadata.execution.http_url.clone()); + + let peers = match client.get_peers().await { + Ok(peers) => peers, + Err(e) => { + outcome.add_check(CheckResult::failure( + node_name, + format!("Error fetching peers: {}", e), + )); + continue; + } + }; + + // Check if all persistent peers are connected + let missing_peers: Vec<_> = persistent_peer_names + .iter() + .filter(|persistent_peer_name| { + // Check if this peer name appears in any connected peer + !peers.iter().any(|peer| { + // Match by checking if the persistent peer name is in the enode + testnet + .nodes_metadata + .get(persistent_peer_name) + .map(|peer_meta| { + if let Ok(url) = reqwest::Url::parse(&peer.enode) { + if let Some(host) = url.host_str() { + // Check if the host is in the private IPs of the peer + let ips = peer_meta.execution.private_ip_addresses(); + return ips.contains(&host.to_string()); + } + } + false + }) + .unwrap_or(false) + }) + }) + .cloned() + .collect(); + + if missing_peers.is_empty() { + outcome.add_check(CheckResult::success( + node_name, + format!( + "All {} persistent peers connected", + persistent_peer_names.len() + ), + )); + } else { + outcome.add_check(CheckResult::failure( + node_name, + format!("Missing persistent peers: {}", missing_peers.join(", ")), + )); + } + } + } + + outcome +} diff --git a/crates/quake/src/tests/sanity.rs b/crates/quake/src/tests/sanity.rs index 7eac4fa0..819bc6f1 100644 --- a/crates/quake/src/tests/sanity.rs +++ b/crates/quake/src/tests/sanity.rs @@ -106,7 +106,9 @@ pub(crate) fn build_spammer_config( show_pool_status: false, tx_latency: false, csv_dir: None, + summary_json: None, wait_response: false, + silent: false, reconnect_attempts: 3, reconnect_period: Duration::from_secs(3), tx_type_mix: Some( @@ -117,6 +119,8 @@ pub(crate) fn build_spammer_config( .parse() .expect("default guzzler weights"), erc20_fn_weights: None, + state_out: None, + state_in: None, }; let config = args.to_config(false, false); config.validate()?; diff --git a/crates/quake/src/tests/snapshot.rs b/crates/quake/src/tests/snapshot.rs index 95c95a58..55bf9f92 100644 --- a/crates/quake/src/tests/snapshot.rs +++ b/crates/quake/src/tests/snapshot.rs @@ -129,7 +129,9 @@ pub(crate) async fn restore_from_snapshot( std::fs::remove_file(&reth_toml).wrap_err("Failed to remove reth.toml from snapshot")?; } - // Replace CL store.db, preserving config/ (contains priv_validator_key.json) + // Replace CL store.db, preserving config/ (contains priv_validator_key.json). + // The WAL goes as well. A real restore keeps it, but this node is rewound onto + // another node's store and never restarts the height its own log holds. let wal_dir = node_malachite.join("wal"); if wal_dir.exists() { std::fs::remove_dir_all(&wal_dir).wrap_err("Failed to remove malachite wal")?; diff --git a/crates/quake/src/tests/tx.rs b/crates/quake/src/tests/tx.rs index bf808ff7..349aed05 100644 --- a/crates/quake/src/tests/tx.rs +++ b/crates/quake/src/tests/tx.rs @@ -19,10 +19,12 @@ use alloy_primitives::{TxKind, U256}; use alloy_signer::Signer; use alloy_signer_local::{coins_bip39::English, MnemonicBuilder}; use color_eyre::eyre::{self, Context}; -use rand::{seq::SliceRandom, thread_rng}; +use indexmap::IndexMap; +use rand::{rngs::StdRng, seq::SliceRandom, SeedableRng}; use tracing::{debug, info}; use super::{quake_test, CheckResult, RpcClientFactory, TestOutcome, TestParams, TestResult}; +use crate::manifest; use crate::testnet::Testnet; /// Test mnemonic matching genesis pre-funded accounts. @@ -33,15 +35,21 @@ const TEST_ACCOUNT_INDEX: u32 = 0; const CHAIN_ID: u64 = 1337; const MAX_PRIORITY_FEE_PER_GAS: u128 = 1_000_000_000; // 1 gwei -const MAX_FEE_PER_GAS: u128 = 2_000_000_000; // 2 gwei +const MAX_FEE_PER_GAS: u128 = 40_000_000_000_000; // 40,000 gwei (2x headroom over the 20,000 gwei maxBaseFee ceiling) const GAS_LIMIT: u64 = 30_000; // sufficient for a simple value transfer on Arc (~26k with blocklist check) /// Default receipt polling timeout. -const DEFAULT_RECEIPT_TIMEOUT_SECS: u64 = 10; +const DEFAULT_RECEIPT_TIMEOUT_SECS: u64 = 20; /// Delay between receipt polling attempts. const RECEIPT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); +/// Maximum time to wait for a late-joining target node to come online. +const TARGET_READINESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Delay between target-readiness polls. +const TARGET_READINESS_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); + /// Submit a value transfer from a pre-funded genesis account to itself and /// verify that the transaction is committed in a block with a success status. #[quake_test(group = "tx", name = "transfer")] @@ -80,52 +88,12 @@ fn transfer_test<'a>( let target_client = factory.create(target_node_url); let receipt_client = factory.create(receipt_node_url); - // Derive signer from test mnemonic - let mut signer = MnemonicBuilder::::default() - .phrase(TEST_MNEMONIC) - .derivation_path(format!("m/44'/60'/1'/0/{account_index}")) - .wrap_err("invalid derivation path")? - .build() - .wrap_err("failed to build signer from mnemonic")?; - - signer.set_chain_id(Some(CHAIN_ID)); - let address = signer.address(); - debug!(%address, "Derived signer"); - - // Query current nonce - let nonce = target_client - .get_transaction_count(&format!("{address:#x}")) - .await - .wrap_err("failed to query nonce")?; - debug!(%nonce, "Current nonce"); - - // Build self-transfer transaction - let tx = TxEip1559 { - chain_id: CHAIN_ID, - nonce, - max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, - max_fee_per_gas: MAX_FEE_PER_GAS, - gas_limit: GAS_LIMIT, - to: TxKind::Call(address), - value: U256::from(1), - input: Default::default(), - access_list: Default::default(), - }; - - // Sign - let sig_hash = tx.signature_hash(); - let signature = signer - .sign_hash(&sig_hash) - .await - .wrap_err("failed to sign transaction")?; - let signed_tx = tx.into_signed(signature); - - // Encode to EIP-2718 - let mut buf = Vec::with_capacity(signed_tx.eip2718_encoded_length()); - signed_tx.eip2718_encode(&mut buf); - let raw_tx = format!("0x{}", hex::encode(&buf)); + if target_starts_late(&testnet.manifest.nodes, &target_node_name) { + wait_for_node_started(&target_client, &target_node_name).await?; + } - // Send + // Build, sign, and send a self-transfer from the test mnemonic account + let raw_tx = sign_self_transfer(&target_client, account_index).await?; let tx_hash = target_client .send_raw_transaction(&raw_tx) .await @@ -225,6 +193,54 @@ fn transfer_test<'a>( }) } +/// Builds and signs an EIP-1559 self-transfer from the test mnemonic account +/// at `account_index`, querying its current (pending) nonce from `client`. +/// Returns the EIP-2718-encoded raw transaction, ready for `send_raw_transaction`. +pub(super) async fn sign_self_transfer( + client: &crate::rpc::RpcClient, + account_index: u32, +) -> eyre::Result { + let mut signer = MnemonicBuilder::::default() + .phrase(TEST_MNEMONIC) + .derivation_path(format!("m/44'/60'/1'/0/{account_index}")) + .wrap_err("invalid derivation path")? + .build() + .wrap_err("failed to build signer from mnemonic")?; + + signer.set_chain_id(Some(CHAIN_ID)); + let address = signer.address(); + debug!(%address, "Derived signer"); + + let nonce = client + .get_transaction_count(&format!("{address:#x}")) + .await + .wrap_err("failed to query nonce")?; + debug!(%nonce, "Current nonce"); + + let tx = TxEip1559 { + chain_id: CHAIN_ID, + nonce, + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + max_fee_per_gas: MAX_FEE_PER_GAS, + gas_limit: GAS_LIMIT, + to: TxKind::Call(address), + value: U256::from(1), + input: Default::default(), + access_list: Default::default(), + }; + + let sig_hash = tx.signature_hash(); + let signature = signer + .sign_hash(&sig_hash) + .await + .wrap_err("failed to sign transaction")?; + let signed_tx = tx.into_signed(signature); + + let mut buf = Vec::with_capacity(signed_tx.eip2718_encoded_length()); + signed_tx.eip2718_encode(&mut buf); + Ok(format!("0x{}", hex::encode(&buf))) +} + fn target_node(testnet: &Testnet, params: &TestParams) -> eyre::Result<(String, reqwest::Url)> { let node_urls = testnet.nodes_metadata.all_execution_urls(); if let Some(node_name) = params.get("target_node") { @@ -232,12 +248,65 @@ fn target_node(testnet: &Testnet, params: &TestParams) -> eyre::Result<(String, return Ok((node_name.to_string(), node_url)); } + let seed = testnet + .seed + .ok_or_else(|| eyre::eyre!("testnet seed is not set"))?; + + pick_random_node(&node_urls, seed) +} + +/// Pick a node deterministically from `seed`. +fn pick_random_node( + node_urls: &[(String, reqwest::Url)], + seed: u64, +) -> eyre::Result<(String, reqwest::Url)> { + let mut rng = StdRng::seed_from_u64(seed); node_urls - .choose(&mut thread_rng()) - .cloned() + .choose(&mut rng) + .map(|(name, url)| (name.clone(), url.clone())) .ok_or_else(|| eyre::eyre!("no nodes available")) } +/// `true` if the manifest configures `name` to start after genesis. Callers +/// gate RPC submission on the node coming online when this is set. +fn target_starts_late(manifest_nodes: &IndexMap, name: &str) -> bool { + manifest_nodes + .get(name) + .and_then(|node| node.start_at) + .unwrap_or(0) + > 0 +} + +/// Poll `eth_blockNumber` on the target until it reports a non-zero height, +/// or [`TARGET_READINESS_TIMEOUT`] elapses. +async fn wait_for_node_started( + client: &crate::rpc::RpcClient, + node_name: &str, +) -> eyre::Result<()> { + let deadline = tokio::time::Instant::now() + TARGET_READINESS_TIMEOUT; + let mut last_err: Option = None; + let mut last_height: Option = None; + loop { + match client.get_latest_block_number_with_retries(0).await { + Ok(height) if height > 0 => { + debug!(node = %node_name, height, "Target node ready"); + return Ok(()); + } + Ok(height) => last_height = Some(height), + Err(e) => last_err = Some(e.to_string()), + } + if tokio::time::Instant::now() >= deadline { + return Err(eyre::eyre!( + "target node {node_name} not serving after {:?} (last height: {:?}, last error: {:?})", + TARGET_READINESS_TIMEOUT, + last_height, + last_err, + )); + } + tokio::time::sleep(TARGET_READINESS_POLL_INTERVAL).await; + } +} + fn named_node_url( node_urls: &[(String, reqwest::Url)], param_name: &str, @@ -358,4 +427,82 @@ mod tests { assert!(err.to_string().contains("receipt_node 'missing' not found")); } + + fn urls(names: &[&str]) -> Vec<(String, reqwest::Url)> { + names + .iter() + .enumerate() + .map(|(i, name)| { + let url = reqwest::Url::parse(&format!("http://127.0.0.1:{}/", 8545 + i)).unwrap(); + ((*name).to_string(), url) + }) + .collect() + } + + fn manifest_nodes(entries: &[(&str, Option)]) -> IndexMap { + entries + .iter() + .map(|(name, start_at)| { + let node = manifest::Node { + start_at: *start_at, + ..manifest::Node::default() + }; + ((*name).to_string(), node) + }) + .collect() + } + + #[test] + fn pick_random_node_is_deterministic_for_same_seed() { + let node_urls = urls(&["val-0", "val-1", "val-2", "val-3"]); + + let first = pick_random_node(&node_urls, 42).unwrap(); + let second = pick_random_node(&node_urls, 42).unwrap(); + + assert_eq!(first, second); + } + + #[test] + fn pick_random_node_selects_per_seed() { + let node_urls = urls(&["val-0", "val-1", "val-2", "val-3"]); + + let picks: std::collections::HashSet = (0..32) + .map(|seed| pick_random_node(&node_urls, seed).unwrap().0) + .collect(); + + assert!( + picks.len() > 1, + "expected different seeds to select different nodes, got {picks:?}", + ); + } + + #[test] + fn pick_random_node_errors_on_empty_set() { + let err = pick_random_node(&[], 0).unwrap_err(); + assert!(err.to_string().contains("no nodes available")); + } + + #[test] + fn target_starts_late_is_true_for_positive_start_at() { + let manifest = manifest_nodes(&[("val-0", Some(100))]); + assert!(target_starts_late(&manifest, "val-0")); + } + + #[test] + fn target_starts_late_is_false_for_zero_start_at() { + let manifest = manifest_nodes(&[("val-0", Some(0))]); + assert!(!target_starts_late(&manifest, "val-0")); + } + + #[test] + fn target_starts_late_is_false_for_unset_start_at() { + let manifest = manifest_nodes(&[("val-0", None)]); + assert!(!target_starts_late(&manifest, "val-0")); + } + + #[test] + fn target_starts_late_is_false_for_unknown_node() { + let manifest = manifest_nodes(&[("val-0", Some(100))]); + assert!(!target_starts_late(&manifest, "val-1")); + } } diff --git a/crates/quake/src/tests/tx_relay.rs b/crates/quake/src/tests/tx_relay.rs new file mode 100644 index 00000000..488838da --- /dev/null +++ b/crates/quake/src/tests/tx_relay.rs @@ -0,0 +1,247 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Verifies `--arc.tx.relays` failover against a real network: a full node +//! relays raw tx submission to validators with failover, and keeps accepting +//! and mining transactions (and answering tx-lifecycle queries) after its +//! primary relay target is killed. Also verifies the relay list wraps +//! around: after failing over to the last entry, killing that entry too +//! (with the first entry back up) must fail over back to the start of the +//! list, not dead-end. Pairs with the `tx-relay-failover.toml` scenario, +//! which is the only manifest that configures `full1`'s relay list. + +use std::time::Duration; + +use color_eyre::eyre::{self, Context}; +use tracing::{info, warn}; + +use super::tx::sign_self_transfer; +use super::{quake_test, CheckResult, RpcClientFactory, TestOutcome, TestParams, TestResult}; +use crate::testnet::Testnet; + +/// Distinct from `tx::transfer_test`'s default account (index 0) so the two +/// tests never race on the same nonce if run in the same session. +const RELAY_TEST_ACCOUNT_INDEX: u32 = 1; + +/// Must match `tx-relay-failover.toml`'s `full1.el.config.arc.tx.relays` order: +/// validator1 primary, validator2 backup. +const PRIMARY_RELAY_TARGET: &str = "validator1"; +const SECONDARY_RELAY_TARGET: &str = "validator2"; +const RELAY_NODE: &str = "full1"; + +const RECEIPT_TIMEOUT_SECS: u64 = 20; +const RECEIPT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); + +/// Sends a tx to a full node that relays to validators with failover, kills +/// the primary relay target, sends a second tx, and confirms the full node +/// keeps accepting and mining transactions and answering tx-lifecycle queries +/// for both. Then restarts the primary and kills the secondary, and sends a +/// third tx to confirm the relay list wraps back around to the primary +/// instead of dead-ending on the last entry. +#[quake_test(group = "tx", name = "relay_failover")] +fn relay_failover_test<'a>( + testnet: &'a Testnet, + factory: &'a RpcClientFactory, + _params: &'a TestParams, +) -> TestResult<'a> { + Box::pin(async move { + // This test is tied to `tx-relay-failover.toml`'s specific topology + // (a `full1` relaying to `validator1`/`validator2`). `tx` isn't a + // default-excluded group, so an unscoped `quake test` run against any + // other manifest (e.g. `localdev-remote-signer.toml`, which has no + // `full1`) would otherwise hit this test — skip gracefully instead of + // failing, matching `mev::pending_state_test`'s convention. + let Some(relay_node_url) = testnet + .nodes_metadata + .all_execution_urls() + .into_iter() + .find(|(name, _)| name == RELAY_NODE) + .map(|(_, url)| url) + else { + warn!("Skipping: no '{RELAY_NODE}' node in this manifest"); + return Ok(()); + }; + let relay_client = factory.create(relay_node_url); + + let mut outcome = TestOutcome::new(); + + // tx1: primary relay target (validator1) is up, sticky selection starts there. + send_and_check_mined(&relay_client, &mut outcome, "tx1_before_kill").await?; + + // Kill the primary relay target. An explicit stop/start (not a timed + // `Perturbation::Kill`, whose single call blocks for a fixed duration + // before auto-restarting) keeps the outage exactly as long as needed, + // with no duration to guess and no background race to manage. + testnet.stop(vec![PRIMARY_RELAY_TARGET.to_string()]).await?; + + let result = + submit_and_check_during_outage(&relay_client, &mut outcome, "tx2", "tx2_after_kill") + .await; + + // Always restart, even on failure, so a failed run doesn't leave the + // testnet at degraded quorum for later tests. + testnet + .start(vec![PRIMARY_RELAY_TARGET.to_string()], false) + .await?; + result?; + + // Confirm consensus has actually resumed with the primary back in the + // validator set before killing the secondary — a restarted node's + // gossipsub mesh can take a few seconds to re-form, and proceeding too + // early can stall the whole network with only 2/4 validators live. + testnet + .wait_rounds(1, Duration::from_secs(RECEIPT_TIMEOUT_SECS)) + .await + .wrap_err("consensus did not resume after restarting the primary relay target")?; + + // tx3: primary is back up, secondary (where sticky selection now + // points, after tx2 failed over) is killed. Failover must wrap + // cyclically back to the primary instead of dead-ending. + testnet + .stop(vec![SECONDARY_RELAY_TARGET.to_string()]) + .await?; + + let result = submit_and_check_during_outage( + &relay_client, + &mut outcome, + "tx3", + "tx3_after_wraparound", + ) + .await; + + testnet + .start(vec![SECONDARY_RELAY_TARGET.to_string()], false) + .await?; + result?; + + outcome + .auto_summary( + "Tx relay failover verified: full node kept accepting and mining transactions \ + and answering tx-lifecycle queries after the primary relay target was killed, \ + and failover wrapped back around to the primary after the secondary was also killed", + "Tx relay failover check(s) failed: {}", + ) + .into_result() + }) +} + +/// Submits a tx while a relay target is down, checks it's locally visible +/// before it's mined, then waits for it to be mined via failover. +async fn submit_and_check_during_outage( + relay_client: &crate::rpc::RpcClient, + outcome: &mut TestOutcome, + tx_label: &str, + mined_check_name: &str, +) -> eyre::Result<()> { + let raw_tx = sign_self_transfer(relay_client, RELAY_TEST_ACCOUNT_INDEX).await?; + let tx_hash = relay_client + .send_raw_transaction(&raw_tx) + .await + .wrap_err(format!("failed to send {tx_label} via relay during outage"))?; + info!(%tx_hash, tx_label, "tx sent while a relay target is down"); + + // The full node retains an accepted relay in its own local pool, so it + // must answer this before the tx is mined — independent of which + // upstream (down) or failover target ends up mining it. See + // docs/tx-forwarding.md. + match relay_client.get_transaction_by_hash(&tx_hash).await { + Ok(Some(_)) => outcome.add_check(CheckResult::success( + format!("{tx_label}_visible_locally_while_pending"), + "full node answered eth_getTransactionByHash for the pending relayed tx", + )), + Ok(None) => outcome.add_check(CheckResult::failure( + format!("{tx_label}_visible_locally_while_pending"), + "full node has no local record of the relayed tx before it was mined", + )), + Err(e) => outcome.add_check(CheckResult::failure( + format!("{tx_label}_visible_locally_while_pending"), + format!("failed to query eth_getTransactionByHash: {e}"), + )), + } + + check_mined(relay_client, outcome, &tx_hash, mined_check_name).await +} + +/// Builds, signs, and sends a self-transfer via `relay_client`, then checks it's mined. +async fn send_and_check_mined( + relay_client: &crate::rpc::RpcClient, + outcome: &mut TestOutcome, + check_name: &str, +) -> eyre::Result<()> { + let raw_tx = sign_self_transfer(relay_client, RELAY_TEST_ACCOUNT_INDEX).await?; + let tx_hash = relay_client + .send_raw_transaction(&raw_tx) + .await + .wrap_err("failed to send transaction via relay")?; + info!(%tx_hash, check_name, "Transaction sent via relay"); + + check_mined(relay_client, outcome, &tx_hash, check_name).await +} + +/// Polls for a mined receipt and records a success/failure check — never +/// bails on a missing or failed receipt, so the caller's outcome always +/// reflects every check performed, matching `tx::transfer_test`'s pattern. +async fn check_mined( + relay_client: &crate::rpc::RpcClient, + outcome: &mut TestOutcome, + tx_hash: &str, + check_name: &str, +) -> eyre::Result<()> { + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(RECEIPT_TIMEOUT_SECS); + let mut receipt = None; + let mut last_error = None; + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + tokio::time::sleep(std::cmp::min(RECEIPT_POLL_INTERVAL, remaining)).await; + match relay_client.get_transaction_receipt(tx_hash).await { + Ok(Some(r)) => { + receipt = Some(r); + break; + } + Ok(None) => {} + Err(e) => last_error = Some(e.to_string()), + } + } + + match receipt { + Some(r) => { + let status = r.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status == "0x1" { + outcome.add_check(CheckResult::success( + check_name, + format!("tx {tx_hash} mined successfully"), + )); + } else { + outcome.add_check(CheckResult::failure( + check_name, + format!("tx {tx_hash} mined with status {status} (expected 0x1)"), + )); + } + } + None => { + let err_suffix = last_error + .map(|e| format!(" (last receipt RPC error: {e})")) + .unwrap_or_default(); + outcome.add_check(CheckResult::failure( + check_name, + format!("tx {tx_hash} not committed after {RECEIPT_TIMEOUT_SECS}s{err_suffix}"), + )); + } + } + + Ok(()) +} diff --git a/crates/quake/src/web.rs b/crates/quake/src/web.rs index b7c4936b..0a0e8b52 100644 --- a/crates/quake/src/web.rs +++ b/crates/quake/src/web.rs @@ -15,7 +15,6 @@ // limitations under the License. use std::collections::{BTreeSet, HashMap}; -use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -33,7 +32,7 @@ use tokio::sync::RwLock; use tracing::{debug, info, trace, warn}; use url::Url; -use crate::latency::REGION_ASSIGNMENTS_FILENAME; +use crate::latency; use crate::manifest::{Manifest, NodeType}; use crate::node::{ContainerKind, NodeName}; use crate::nodes::NodesMetadata; @@ -147,7 +146,13 @@ pub(crate) async fn run_server( container_refresh_ms, ); - let region_assignments = Arc::new(load_region_assignments(&testnet.dir)); + let region_assignments = match latency::load_region_assignments(&testnet.dir) { + Ok(map) => Arc::new(map.into_iter().map(|(k, v)| (k, v.to_string())).collect()), + Err(e) => { + warn!("Failed to load region assignments: {e}"); + Arc::new(HashMap::new()) + } + }; let state = AppState { testnet: Arc::new(RwLock::new(testnet)), @@ -846,24 +851,6 @@ fn merge_per_node_errors( // ── Manifest-based topology ───────────────────────────────────────────── -/// Load region assignments from the testnet's `region_assignments.json` file. -/// -/// Returns an empty map if the file doesn't exist or can't be parsed -/// (e.g. latency emulation was never enabled for this testnet). -fn load_region_assignments(testnet_dir: &Path) -> HashMap { - let path = testnet_dir.join(REGION_ASSIGNMENTS_FILENAME); - match std::fs::read_to_string(&path) { - Ok(contents) => match serde_json::from_str(&contents) { - Ok(map) => map, - Err(e) => { - warn!(?path, "Failed to parse {REGION_ASSIGNMENTS_FILENAME}: {e}"); - HashMap::new() - } - }, - Err(_) => HashMap::new(), - } -} - /// Build the list of graph nodes from the manifest. fn build_node_list( manifest: &Manifest, @@ -889,7 +876,7 @@ fn build_node_list( GraphNode { name: name.clone(), node_type: node_type.to_string(), - consensus_enabled: node.cl_config.consensus_enabled(), + consensus_enabled: !node.cl_config.no_consensus, status: NODE_STATUS_OK.to_string(), subnets, height: None, diff --git a/crates/quake/templates/local/arc_builders.yaml.hbs b/crates/quake/templates/local/arc_builders.yaml.hbs index e7c747dc..fc5972f9 100644 --- a/crates/quake/templates/local/arc_builders.yaml.hbs +++ b/crates/quake/templates/local/arc_builders.yaml.hbs @@ -17,7 +17,7 @@ services: FEATURES: ${EL_FEATURES:-default js-tracer} ARC_IDEMPOTENT_BUILD: ${ARC_IDEMPOTENT_BUILD:-false} secrets: - - GITHUB_TOKEN + - github_token image: {{this.tag}} {{/each}} {{#each malachite_builds}} @@ -33,10 +33,10 @@ services: FEATURES: ${CL_FEATURES:-} ARC_IDEMPOTENT_BUILD: ${ARC_IDEMPOTENT_BUILD:-false} secrets: - - GITHUB_TOKEN + - github_token image: {{this.tag}} {{/each}} secrets: - GITHUB_TOKEN: + github_token: environment: GITHUB_TOKEN diff --git a/crates/quake/templates/local/compose.yaml.hbs b/crates/quake/templates/local/compose.yaml.hbs index d94e7eb5..4825d79a 100644 --- a/crates/quake/templates/local/compose.yaml.hbs +++ b/crates/quake/templates/local/compose.yaml.hbs @@ -19,7 +19,7 @@ services: {{#each nodes}} {{execution.inner.name}}: container_name: {{execution.inner.name}} - image: {{../images.el}} + image: {{execution.image}} entrypoint: ["/app/assets/entrypoint_el.sh"] cap_add: - NET_ADMIN @@ -62,9 +62,18 @@ services: - "{{execution.metrics_port}}:9001" # Metrics - "{{execution.pprof_port}}:6061" # Pprof environment: + {{#unless (contains_key el_env "PATH")}} PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + {{/unless}} + {{#unless (contains_key el_env "ARC_LOG_FILE")}} ARC_LOG_FILE: /var/log/arc/{{execution.inner.name}}.log + {{/unless}} + {{#unless (contains_key el_env "RUST_LOG")}} RUST_LOG: debug,net::peers=trace,net::discovery=trace,net::discv5=trace + {{/unless}} + {{#each el_env}} + {{@key}}: "{{{yaml_dq_escape this}}}" + {{/each}} command: - "node" - "--datadir=/data/reth/execution-data" @@ -79,7 +88,7 @@ services: - "--log.file.directory=/var/log/arc" - "--log.file.max-files=0" {{#each execution.cli_flags}} - - "{{{this}}}" + - "{{{yaml_dq_escape this}}}" {{/each}} - "--arc-rpc-upstream-url=http://{{consensus.inner.name}}:31000" {{#if ../rpc}} @@ -99,7 +108,7 @@ services: {{consensus.inner.name}}: container_name: {{consensus.inner.name}} - image: {{../images.cl}} + image: {{consensus.image}} entrypoint: ["/app/assets/entrypoint_cl.sh"] cap_add: - NET_ADMIN @@ -140,14 +149,23 @@ services: - "{{consensus.pprof_port}}:6060" # Pprof - "{{consensus.rpc_port}}:31000" # RPC environment: + {{#unless (contains_key cl_env "ARC_LOG_FILE")}} ARC_LOG_FILE: /var/log/arc/{{consensus.inner.name}}.log + {{/unless}} + {{#unless (contains_key cl_env "ARC_HALT_AT_BLOCK_HEIGHT")}} ARC_HALT_AT_BLOCK_HEIGHT: 0 + {{/unless}} + {{#unless (contains_key cl_env "ARC_GENESIS_FILE_PATH")}} ARC_GENESIS_FILE_PATH: /app/assets/genesis.json + {{/unless}} + {{#each cl_env}} + {{@key}}: "{{{yaml_dq_escape this}}}" + {{/each}} command: - "start" - "--home=/app/.malachite" {{#each consensus.cli_flags}} - - "{{{this}}}" + - "{{{yaml_dq_escape this}}}" {{/each}} {{#if ../rpc}} - "--eth-rpc-endpoint=http://{{execution.inner.name}}:8545" @@ -206,9 +224,18 @@ services: - "{{execution.metrics_port}}:9001" # Metrics - "{{execution.pprof_port}}:6061" # Pprof environment: + {{#unless (contains_key el_env "PATH")}} PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + {{/unless}} + {{#unless (contains_key el_env "ARC_LOG_FILE")}} ARC_LOG_FILE: /var/log/arc/{{execution.inner.name}}.log + {{/unless}} + {{#unless (contains_key el_env "RUST_LOG")}} RUST_LOG: debug,net::peers=trace,net::discovery=trace,net::discv5=trace + {{/unless}} + {{#each el_env}} + {{@key}}: "{{{yaml_dq_escape this}}}" + {{/each}} command: - "node" - "--datadir=/data/reth/execution-data" @@ -223,7 +250,7 @@ services: - "--log.file.directory=/var/log/arc" - "--log.file.max-files=0" {{#each execution.cli_flags}} - - "{{{this}}}" + - "{{{yaml_dq_escape this}}}" {{/each}} - "--arc-rpc-upstream-url=http://{{consensus.inner.name}}{{#if ../images.cl_upgrade}}_u{{/if}}:31000" {{#if ../rpc}} @@ -287,14 +314,23 @@ services: - "{{consensus.pprof_port}}:6060" # Pprof - "{{consensus.rpc_port}}:31000" # RPC environment: + {{#unless (contains_key cl_env "ARC_LOG_FILE")}} ARC_LOG_FILE: /var/log/arc/{{consensus.inner.name}}.log + {{/unless}} + {{#unless (contains_key cl_env "ARC_HALT_AT_BLOCK_HEIGHT")}} ARC_HALT_AT_BLOCK_HEIGHT: 0 + {{/unless}} + {{#unless (contains_key cl_env "ARC_GENESIS_FILE_PATH")}} ARC_GENESIS_FILE_PATH: /app/assets/genesis.json + {{/unless}} + {{#each cl_env}} + {{@key}}: "{{{yaml_dq_escape this}}}" + {{/each}} command: - "start" - "--home=/app/.malachite" {{#each consensus.cli_flags_upgraded}} - - "{{{this}}}" + - "{{{yaml_dq_escape this}}}" {{/each}} {{#if ../rpc}} - "--eth-rpc-endpoint=http://{{execution.inner.name}}_u:8545" @@ -321,7 +357,7 @@ services: environment: <<: *aws-base APP_ENV: ${APP_ENV:-dev} - APP_PROVIDER_ENCLAVE_CLIENT_BASEURL: http://{{name}}-signer-enclave:10350 + APP_PROVIDER_ENCLAVE_CLIENT_BASEURL: {{name}}-signer-enclave:10350 APP_PROVIDER_SECRETS_LOCALSTACK_ENABLED: false APP_SERVICE_SIGNER_KEYID: ${APP_SERVICE_SIGNER{{inc @index}}_KEYID:-dev/arc/remote-signer/{{remote_signer}}} APP_PROVIDER_AWSKMS_ARNS: ${APP_PROVIDER_AWSKMS_ARNS} @@ -356,7 +392,7 @@ services: memlock: -1 entrypoint: ["/usr/local/circle/run_enclave.dev.sh"] healthcheck: - test: ["CMD-SHELL", "curl -f --insecure http://localhost:10350/ping"] + test: ["CMD", "grpc_health_probe", "-addr=localhost:10350"] <<: *health-retry {{/if}} diff --git a/crates/quake/templates/remote/compose-node.yaml.hbs b/crates/quake/templates/remote/compose-node.yaml.hbs index e4ca9ad1..83e0e4f1 100644 --- a/crates/quake/templates/remote/compose-node.yaml.hbs +++ b/crates/quake/templates/remote/compose-node.yaml.hbs @@ -43,8 +43,15 @@ services: - shared-socket-volume:/sockets/ {{/unless}} environment: + {{#unless (contains_key el_env "PATH")}} PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + {{/unless}} + {{#unless (contains_key el_env "ARC_LOG_FILE")}} ARC_LOG_FILE: /var/log/arc/{{el_container_name}}.log + {{/unless}} + {{#each el_env}} + {{@key}}: "{{{yaml_dq_escape this}}}" + {{/each}} command: - "node" - "--datadir=/data/reth" @@ -114,8 +121,15 @@ services: - shared-socket-volume:/sockets/ {{/unless}} environment: + {{#unless (contains_key cl_env "ARC_LOG_FILE")}} ARC_LOG_FILE: /var/log/arc/{{cl_container_name}}.log + {{/unless}} + {{#unless (contains_key cl_env "ARC_GENESIS_FILE_PATH")}} ARC_GENESIS_FILE_PATH: /assets/genesis.json + {{/unless}} + {{#each cl_env}} + {{@key}}: "{{{yaml_dq_escape this}}}" + {{/each}} command: # - "--log-format=json" - "start" diff --git a/crates/quake/terraform/cc-data.yaml b/crates/quake/terraform/cc-data.yaml index 7dd8c545..32724ffc 100644 --- a/crates/quake/terraform/cc-data.yaml +++ b/crates/quake/terraform/cc-data.yaml @@ -56,77 +56,6 @@ write_files: [ -f "$f" ] && [ "$(cat "$f")" != "0" ] && FAILED=1 done exit $FAILED - - path: /home/${username}/download-metrics.sh - owner: root:root - permissions: "0755" - content: | - #!/bin/bash - # Usage: download-metrics.sh [-s ] [-e ] [-t ] [metric1 metric2 ...] - # Queries Prometheus query_range and saves each metric to a JSON file, - # then creates ~/quake-metrics-tmp-.tar.gz. - # Outputs a single JSON line: {"archive":"","errors":[": ",...]} - # Per-metric failures are collected and reported; the archive is always created. - # -t sets the query resolution (interval between data points, e.g. 30s, 1m, 5m). - # Defaults to ceil((end-start)/10000) to stay within Prometheus' 11,000-point limit. - set -uo pipefail - START="" - END="" - STEP="" - while getopts "s:e:t:" OPT; do - case "$OPT" in - s) START="$OPTARG" ;; - e) END="$OPTARG" ;; - t) STEP="$OPTARG" ;; - *) exit 1 ;; - esac - done - shift "$((OPTIND-1))" - NOW=$(date +%s) - if [ -z "$START" ]; then - START=$(curl -sf http://localhost:9090/api/v1/status/tsdb | jq -r '.data.headStats.minTime / 1000 | floor' 2>/dev/null) || START=0 - fi - [ -z "$END" ] && END=$NOW - if [ -z "$STEP" ]; then - STEP=$(( (END - START + 9999) / 10000 )) - [ "$STEP" -lt 1 ] && STEP=1 - STEP="$STEP"s - fi - PARAMS="start=$START&end=$END&step=$STEP" - TMP_DIR=$(mktemp -d) - trap "rm -rf $TMP_DIR" EXIT - if [ $# -eq 0 ]; then - METRICS_RESPONSE=$(curl -sf http://localhost:9090/api/v1/label/__name__/values) - CURL_EXIT=$? - if [ $CURL_EXIT -ne 0 ]; then - printf '{"archive":"","errors":["failed to list Prometheus metric names (curl exited with code %s)"]}\n' "$CURL_EXIT" - exit 0 - fi - METRICS=$(printf '%s' "$METRICS_RESPONSE" | jq -r '.data[]') - else - METRICS=$(printf '%s\n' "$@") - fi - ARCHIVE="quake-metrics-tmp-$$.tar.gz" - ERRORS=() - while IFS= read -r M; do - if RESPONSE=$(curl -s "http://localhost:9090/api/v1/query_range?query=$M&$PARAMS" 2>/dev/null); then - STATUS=$(printf '%s' "$RESPONSE" | jq -r '.status // "error"' 2>/dev/null) || STATUS="error" - if [ "$STATUS" = "success" ]; then - printf '%s' "$RESPONSE" > "$TMP_DIR/$M.json" - else - ERR=$(printf '%s' "$RESPONSE" | jq -r '.error // "unknown error"' 2>/dev/null || echo "unknown error") - ERRORS+=("$M: $ERR") - fi - else - ERRORS+=("$M: curl failed") - fi - done <<< "$METRICS" - tar czf ~/$ARCHIVE -C "$TMP_DIR" . - if [ $${#ERRORS[@]} -eq 0 ]; then - ERRORS_JSON="[]" - else - ERRORS_JSON=$(printf '%s\n' "$${ERRORS[@]}" | jq -R . | jq -s .) - fi - printf '{"archive":"%s","errors":%s}\n' "$ARCHIVE" "$ERRORS_JSON" - path: /home/${username}/download-db.sh owner: root:root permissions: "0755" diff --git a/crates/quake/terraform/cc.tf b/crates/quake/terraform/cc.tf index 0dc1df99..89dd798e 100644 --- a/crates/quake/terraform/cc.tf +++ b/crates/quake/terraform/cc.tf @@ -228,6 +228,7 @@ resource "terraform_data" "spammer-image" { provisioner "local-exec" { command = <<-EOT docker build -q \ + --secret id=github_token,env=GITHUB_TOKEN \ --build-arg CIRCLE_BASE_IMAGE=${var.circle_base_image} \ --platform linux/amd64 \ -f crates/spammer/Dockerfile \ @@ -235,6 +236,9 @@ resource "terraform_data" "spammer-image" { docker save spammer:latest | gzip > /tmp/spammer_latest.tar.gz EOT working_dir = local.root_dir + environment = { + GITHUB_TOKEN = var.github_token + } } } diff --git a/crates/quake/terraform/nodes-data.yaml b/crates/quake/terraform/nodes-data.yaml index 29395294..71bed59b 100644 --- a/crates/quake/terraform/nodes-data.yaml +++ b/crates/quake/terraform/nodes-data.yaml @@ -136,7 +136,7 @@ runcmd: - sysctl -p /etc/sysctl.d/99-swap.conf # Install packages via dnf (Amazon Linux 2023) # Note: curl-minimal, jq, nfs-utils are pre-installed - - dnf install -y docker tmux iproute-tc + - dnf install -y docker tmux iproute-tc mdadm # Activate the secondary ENI service. For bridge nodes (EXPECTED > 0), the # oneshot start blocks until ENIs are fully configured — before Docker starts. - systemctl daemon-reload @@ -153,6 +153,35 @@ runcmd: - docker pull ${consensus_image} - docker pull ${execution_image} - docker images +# Optionally relocate the node data directory onto local instance-store NVMe +# (set by --node-data-on-instance-store). Must run before the data dirs are +# created and before Docker bind-mounts them. Instance-store volumes are detected +# by device model rather than nvmeN numbering, which is not stable across boots. +# Multiple instance-store volumes are striped RAID0 into one device. A no-op when +# the flag is off or the instance type has no instance store. Shell vars avoid the +# `$${...}` form so Terraform templatefile does not treat them as interpolations. + - | + if [ "${data_on_instance_store}" = "true" ]; then + DEVS=$(lsblk -dno NAME,MODEL | awk '/Amazon EC2 NVMe Instance Storage/{print "/dev/"$1}') + COUNT=$(printf '%s\n' "$DEVS" | grep -c .) + TARGET="" + if [ "$COUNT" -eq 1 ]; then + TARGET="$DEVS" + mkfs.xfs -f "$TARGET" || { echo "ERROR: mkfs failed on $TARGET"; exit 1; } + elif [ "$COUNT" -gt 1 ]; then + mdadm --create --run --verbose /dev/md0 --level=0 --raid-devices="$COUNT" $DEVS || { echo "ERROR: mdadm RAID0 create failed"; exit 1; } + TARGET=/dev/md0 + mkfs.xfs -f "$TARGET" || { echo "ERROR: mkfs failed on $TARGET"; exit 1; } + fi + if [ -n "$TARGET" ]; then + echo "Node data directory on instance-store NVMe ($COUNT device(s)) -> $TARGET" + mkdir -p /home/${username}/data + mount "$TARGET" /home/${username}/data || { echo "ERROR: mount failed for $TARGET"; exit 1; } + echo "$TARGET /home/${username}/data xfs defaults,nofail 0 0" >> /etc/fstab + else + echo "WARNING: --node-data-on-instance-store set but no instance-store NVMe found; data directory stays on root EBS." + fi + fi # Create directories for Arc files (config will be symlinked to NFS after mount) - mkdir -p /home/${username}/data/malachite - mkdir -p /home/${username}/data/reth diff --git a/crates/quake/terraform/nodes.tf b/crates/quake/terraform/nodes.tf index da611190..1647f7d6 100644 --- a/crates/quake/terraform/nodes.tf +++ b/crates/quake/terraform/nodes.tf @@ -40,6 +40,7 @@ resource "aws_instance" "node" { github_user = var.github_user github_token = var.github_token expected_secondary_enis = length(local.node_secondary_networks[var.node_names[count.index]]) + data_on_instance_store = var.node_data_on_instance_store }) tags = merge( diff --git a/crates/quake/terraform/variables.tf b/crates/quake/terraform/variables.tf index c1a06438..ce5b8f06 100644 --- a/crates/quake/terraform/variables.tf +++ b/crates/quake/terraform/variables.tf @@ -96,6 +96,14 @@ variable "node_volume_iops" { nullable = true } +# Place the node data directory on local instance-store NVMe instead of the root EBS volume. +# Override via `quake remote create --node-data-on-instance-store`. Requires an instance type +# with local NVMe (e.g. i4i.*, i3.*, m6id.*); a no-op on instance types without instance store. +variable "node_data_on_instance_store" { + type = bool + default = false +} + variable "tags" { type = list(string) default = ["arc-quake-testnet"] diff --git a/crates/quake/tests/upgrade.md b/crates/quake/tests/upgrade.md index c614c319..d85241d7 100644 --- a/crates/quake/tests/upgrade.md +++ b/crates/quake/tests/upgrade.md @@ -14,8 +14,7 @@ Create a temporary manifest file with three validators: ```sh {empty_output} $ export MANIFEST=/tmp/quake-test-upgrade.toml $ cat > $MANIFEST << 'EOF' -engine_api_connection = "rpc" -el_init_hardfork="zero4" +el_init_hardfork="zero6" # IMAGE_REGISTRY_URL should be defined as an environment variable or in the .env file. image_cl="${IMAGE_REGISTRY_URL}/arc-consensus:latest" diff --git a/crates/remote-signer/src/client.rs b/crates/remote-signer/src/client.rs index dca61af6..77892351 100644 --- a/crates/remote-signer/src/client.rs +++ b/crates/remote-signer/src/client.rs @@ -301,8 +301,7 @@ mod integration_tests { let result = RemoteSignerClient::new(config).await; // With lazy connection, client creation may succeed but operations will fail - if result.is_ok() { - let client = result.unwrap(); + if let Ok(client) = result { // Try to get public key - this should fail let public_key_result = client.get_public_key().await; assert!( @@ -380,8 +379,7 @@ mod integration_tests { let result = RemoteSignerClient::new(config).await; // With lazy connection, client creation may succeed but operations will fail - if result.is_ok() { - let client = result.unwrap(); + if let Ok(client) = result { // Try to sign a message - this should fail with retry exhaustion let sign_result = client.sign_message(b"test").await; assert!( @@ -394,4 +392,30 @@ mod integration_tests { } } } + + #[tokio::test] + async fn config_builder_pattern() { + let config = RemoteSigningConfig::default() + .with_timeout(Duration::from_secs(10)) + .with_retry_config(RetryConfig::new( + 2, + Duration::from_millis(50), + Duration::from_secs(1), + )); + + assert_eq!(config.endpoint, "http://0.0.0.0:10340"); + assert_eq!(config.timeout, Duration::from_secs(10)); + assert_eq!(config.retry_config.max_retries, 2); + } + + #[tokio::test] + async fn retry_config_validation() { + let retry_config = RetryConfig::new(5, Duration::from_millis(100), Duration::from_secs(10)) + .with_backoff_multiplier(1.5); + + assert_eq!(retry_config.max_retries, 5); + assert_eq!(retry_config.initial_backoff, Duration::from_millis(100)); + assert_eq!(retry_config.max_backoff, Duration::from_secs(10)); + assert_eq!(retry_config.backoff_multiplier, 1.5); + } } diff --git a/crates/remote-signer/src/config.rs b/crates/remote-signer/src/config.rs index a5814bee..be322598 100644 --- a/crates/remote-signer/src/config.rs +++ b/crates/remote-signer/src/config.rs @@ -203,30 +203,4 @@ mod tests { }; assert!(config.validate().is_ok()); } - - #[test] - fn test_config_builder_pattern() { - let config = RemoteSigningConfig::default() - .with_timeout(Duration::from_secs(10)) - .with_retry_config(RetryConfig::new( - 2, - Duration::from_millis(50), - Duration::from_secs(1), - )); - - assert_eq!(config.endpoint, "http://0.0.0.0:10340"); - assert_eq!(config.timeout, Duration::from_secs(10)); - assert_eq!(config.retry_config.max_retries, 2); - } - - #[test] - fn test_retry_config_validation() { - let retry_config = RetryConfig::new(5, Duration::from_millis(100), Duration::from_secs(10)) - .with_backoff_multiplier(1.5); - - assert_eq!(retry_config.max_retries, 5); - assert_eq!(retry_config.initial_backoff, Duration::from_millis(100)); - assert_eq!(retry_config.max_backoff, Duration::from_secs(10)); - assert_eq!(retry_config.backoff_multiplier, 1.5); - } } diff --git a/crates/remote-signer/src/provider.rs b/crates/remote-signer/src/provider.rs index b1397301..038d6077 100644 --- a/crates/remote-signer/src/provider.rs +++ b/crates/remote-signer/src/provider.rs @@ -22,7 +22,7 @@ use async_trait::async_trait; use eyre::eyre; use malachitebft_core_types::{ - Context, SignedExtension, SignedProposal, SignedVote, ValidatorProof, + Context, SignedExtension, SignedProposal, SignedVote, ValidatorProof, VoteExtensionScope, }; use malachitebft_signing::{Error as SigningError, Signer, VerificationResult, Verifier}; @@ -154,6 +154,7 @@ impl Signer for RemoteSigningProvider { async fn sign_vote_extension( &self, + _scope: VoteExtensionScope, _extension: ::Extension, ) -> Result, SigningError> { unreachable!("Vote extensions are not supported in Arc"); @@ -194,6 +195,7 @@ impl Verifier for RemoteSigningProvider { async fn verify_signed_vote_extension( &self, + _scope: &VoteExtensionScope, _extension: &::Extension, _signature: &ConsensusSignature, _public_key: &PublicKey, @@ -356,18 +358,14 @@ mod integration_tests { let config = RemoteSigningConfig::new("http://localhost:9999".to_string()); let result = RemoteSigningProvider::new(config).await; - // With lazy connection, provider creation may succeed but operations will fail - if result.is_ok() { - let provider = result.unwrap(); - // Try to get public key - this should fail + // With lazy connection, provider creation may succeed but operations will fail. + // If it failed outright, that is also acceptable. + if let Ok(provider) = result { let public_key_result = provider.public_key().await; assert!( public_key_result.is_err(), "Public key retrieval should fail with bad endpoint" ); - } else { - // If provider creation failed, that's also acceptable - assert!(result.is_err()); } } diff --git a/crates/signer/src/lib.rs b/crates/signer/src/lib.rs index 186e4c38..6e4dbe05 100644 --- a/crates/signer/src/lib.rs +++ b/crates/signer/src/lib.rs @@ -23,7 +23,7 @@ pub use arc_consensus_types::signing::{ pub use arc_consensus_types::signing::{SignedExtension, SignedProposal, SignedVote}; use arc_consensus_types::{ArcContext, Proposal, Vote}; -use malachitebft_core_types::ValidatorProof; +use malachitebft_core_types::{ValidatorProof, VoteExtensionScope}; #[cfg(not(any(feature = "local", feature = "remote")))] compile_error!("At least one signing provider feature must be enabled"); @@ -86,6 +86,7 @@ impl Signer for ArcSigningProvider { async fn sign_vote_extension( &self, + _scope: VoteExtensionScope, _extension: Bytes, ) -> Result, SigningError> { unreachable!("Vote extensions are not supported in Arc at the moment"); @@ -156,6 +157,7 @@ impl Verifier for ArcSigningProvider { async fn verify_signed_vote_extension( &self, + _scope: &VoteExtensionScope, _extension: &Bytes, _signature: &Signature, _public_key: &PublicKey, diff --git a/crates/signer/src/local.rs b/crates/signer/src/local.rs index 51e161e5..998abbf4 100644 --- a/crates/signer/src/local.rs +++ b/crates/signer/src/local.rs @@ -21,10 +21,30 @@ use arc_consensus_types::signing::{SignedExtension, SignedProposal, SignedVote, use arc_consensus_types::signing::{Signer, SigningProvider, VerificationResult, Verifier}; use crate::{ArcContext, Proposal, Vote}; -use malachitebft_core_types::ValidatorProof; +use malachitebft_core_types::{NilOrVal, ValidatorProof, VoteExtensionScope}; pub use malachitebft_signing_ed25519::*; +const VOTE_EXTENSION_DOMAIN: &[u8] = b"malachitebft/vote-extension/v1\0"; + +fn vote_extension_sign_bytes(scope: &VoteExtensionScope, extension: &Bytes) -> Vec { + let precommit = Vote::new_precommit( + scope.height, + scope.round, + NilOrVal::Val(scope.value_id), + scope.validator_address, + ); + let precommit_bytes = precommit.to_sign_bytes(); + + let mut buf = Vec::new(); + buf.extend_from_slice(VOTE_EXTENSION_DOMAIN); + buf.extend_from_slice(&(precommit_bytes.len() as u64).to_be_bytes()); + buf.extend_from_slice(&precommit_bytes); + buf.extend_from_slice(&(extension.len() as u64).to_be_bytes()); + buf.extend_from_slice(extension); + buf +} + #[derive(Clone)] pub struct LocalSigningProvider { private_key: PrivateKey, @@ -89,9 +109,11 @@ impl Signer for LocalSigningProvider { async fn sign_vote_extension( &self, + scope: VoteExtensionScope, extension: Bytes, ) -> Result, SigningError> { - let signature = self.private_key.sign(extension.as_ref()); + let preimage = vote_extension_sign_bytes(&scope, &extension); + let signature = self.private_key.sign(&preimage); Ok(SignedExtension::new(extension, signature)) } @@ -134,12 +156,14 @@ impl Verifier for LocalSigningProvider { async fn verify_signed_vote_extension( &self, + scope: &VoteExtensionScope, extension: &Bytes, signature: &Signature, public_key: &PublicKey, ) -> Result { + let preimage = vote_extension_sign_bytes(scope, extension); Ok(VerificationResult::from_bool( - public_key.verify(extension.as_ref(), signature).is_ok(), + public_key.verify(&preimage, signature).is_ok(), )) } @@ -181,11 +205,23 @@ impl SigningProvider for LocalSigningProvider { mod tests { use super::*; + use arc_consensus_types::{Address, BlockHash, Height, ValueId}; + use malachitebft_core_types::Round; + fn test_provider() -> LocalSigningProvider { let private_key = PrivateKey::generate(rand::thread_rng()); LocalSigningProvider::new(private_key) } + fn test_scope(validator_byte: u8) -> VoteExtensionScope { + VoteExtensionScope::new( + Height::new(7), + Round::new(2), + ValueId::new(BlockHash::repeat_byte(0xAB)), + Address::repeat_byte(validator_byte), + ) + } + #[test] fn debug_impl_redacts_private_key() { let provider = test_provider(); @@ -208,4 +244,52 @@ mod tests { let signature = other.sign(data); assert!(!provider.verify(data, &signature, &provider.public_key())); } + + #[tokio::test] + async fn vote_extension_sign_verify_roundtrip() { + let provider = test_provider(); + let scope = test_scope(0x01); + let extension = Bytes::from_static(b"test extension"); + + let signed = provider + .sign_vote_extension(scope.clone(), extension.clone()) + .await + .unwrap(); + assert_eq!(signed.message, extension); + + let result = provider + .verify_signed_vote_extension( + &scope, + &signed.message, + &signed.signature, + &provider.public_key(), + ) + .await + .unwrap(); + assert!(result.is_valid()); + } + + // A signature produced for one scope must not verify against a different + // scope; this guards the domain separation in `vote_extension_sign_bytes`. + #[tokio::test] + async fn vote_extension_signature_is_bound_to_scope() { + let provider = test_provider(); + let extension = Bytes::from_static(b"test extension"); + + let signed = provider + .sign_vote_extension(test_scope(0x01), extension.clone()) + .await + .unwrap(); + + let result = provider + .verify_signed_vote_extension( + &test_scope(0x02), + &signed.message, + &signed.signature, + &provider.public_key(), + ) + .await + .unwrap(); + assert!(!result.is_valid()); + } } diff --git a/crates/snapshots/Cargo.toml b/crates/snapshots/Cargo.toml index e32c5824..c13b461e 100644 --- a/crates/snapshots/Cargo.toml +++ b/crates/snapshots/Cargo.toml @@ -21,7 +21,7 @@ clap = { workspace = true, features = ["derive", "env"] } directories.workspace = true eyre.workspace = true lz4.workspace = true -reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls-native-roots"] } +reqwest = { workspace = true, features = ["blocking", "json", "rustls"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true tar.workspace = true diff --git a/crates/snapshots/README.md b/crates/snapshots/README.md index 9d4710a1..a8ac666b 100644 --- a/crates/snapshots/README.md +++ b/crates/snapshots/README.md @@ -1,44 +1,219 @@ # arc-snapshots -Download and extract Arc node snapshots. +Download and restore Arc node snapshots. ## Overview -Arc node snapshots are separate `.tar.lz4` archives for the execution layer (EL) and consensus layer (CL): +An Arc node stores two data layers, execution (EL) and consensus (CL), and +`arc-snapshots` restores both. + +Published snapshots use reth's storage v2 format. Rather than shipping the +execution layer as one compressed archive, it publishes a manifest listing each +database component on its own, which is what lets `--el-profile` fetch part of a +snapshot instead of all of it. The consensus layer is still a single `.tar.lz4` +archive. The rest of this document simply calls these snapshots. + +Automatic resolution always selects an execution manifest and its matching +consensus archive. Explicit URLs can use either of the restore styles below. The +execution artifact chooses the style: + +- **Manifest** — when the execution snapshot is a reth manifest (a URL whose + last path segment is `manifest.json`), the execution layer is downloaded by + handing off to `arc-node-execution download`, and the consensus layer is + restored from a `.tar.lz4` archive. This needs the `arc-node-execution` + binary (see [Execution binary](#execution-binary)). +- **Archive** — when the execution snapshot is a single `.tar.lz4` archive, + both layers are restored by arc-snapshots itself, with no dependency on + `arc-node-execution`. + +When URLs are resolved automatically, the tool selects the newest published +entry that carries both layers, regardless of retention. This skips a newer +entry whose consensus upload has not finished in favor of an older usable one. +The chosen block is logged. + +The native `.tar.lz4` archives contain: | Archive | Contents | |---------|----------| -| Execution (`*-execution-*.tar.lz4`) | `db/`, `db/mdbx.dat`, `db/mdbx.lck`, `db/database.version` | -| Consensus (`*-consensus-*.tar.lz4`) | `store.db` | - +| Execution (explicit `--execution-url` only) | `db/` and `static_files/` | +| Consensus (`consensus.tar.lz4`) | `store.db` | ## `arc-snapshots` CLI -Download both EL and CL snapshots and extract them to their respective data directories. -The latest snapshot URLs are fetched automatically from the API: +Restore both layers, resolving the latest snapshot for the chain automatically: ```bash -# Testnet (default) -arc-snapshots download - -# Devnet -arc-snapshots download --chain arc-devnet +arc-snapshots download --chain arc-testnet --el-profile full ``` -To use specific snapshot URLs instead of auto-fetching, provide both explicitly: +Supported chains: `arc-testnet`, `arc-devnet`, `arc-mainnet`. + +Automatic resolution requires the API to publish a storage v2 listing. A +deployment that omits it fails with an error naming the missing `v2Snapshots` +field. An empty listing, or one with no complete entry for the selected chain, +also fails. There is no fallback to any other listing the API serves. + +To point at specific snapshots instead of resolving them, pass both URLs. The +flags are all-or-nothing because both snapshots must be from the same block. A +consensus snapshot from a different block leaves the node unable to hand off +between the layers, which appears as slow syncing rather than an error. ```bash arc-snapshots download \ + --chain arc-testnet \ + --el-profile full \ --execution-url \ --consensus-url ``` +### Execution profiles (manifest downloads) + +For a manifest download, `--el-profile` chooses how much execution-layer +history to fetch: + +- `minimal`: state, all headers, and a small recent window. Suits validators + and sentries. +- `full`: adds full transaction, receipt, and changeset history. Suits follow + nodes. +- `archive`: every component, including transaction senders and rocksdb + indices. + +The flag defaults to `minimal`, including when an explicit `--execution-url` +names a manifest. Pass the profile that matches the pruning preset the node will +run with: `--el-profile full` for a node started with `--full`. An archive node +must pass `--el-profile archive`; omitting it produces a minimal restore. + +A single-archive execution snapshot ignores `--el-profile`. + +Automatic manifest URLs use the API's query-free +`{base}/download/{manifestKey}` form. Do not supply a presigned manifest URL by +hand. Reth derives every component URL from the manifest URL and preserves its +query string, so the manifest's signature would be attached to each component +request and those requests would return 404. + +### Execution binary + +For a manifest download, `arc-snapshots` runs `arc-node-execution` to download +the execution layer. It looks for `arc-node-execution` on `PATH` by default; set +`ARC_EXECUTION_BINARY` to use a different name or an absolute path. An archive +restore needs no execution binary. + +Before deleting anything, a manifest restore checks that the binary can perform +the download it is about to be asked for. A binary that is missing, too old, or +otherwise unusable fails the restore with the existing data still in place. + ### Options | Flag | Description | Default | |------|-------------|---------| -| `--execution-url` | URL of the EL snapshot archive | auto-fetched from API | -| `--consensus-url` | URL of the CL snapshot archive | auto-fetched from API | -| `--chain` | Network: `arc-testnet`, `arc-devnet` | `arc-testnet` | +| `--el-profile` | Execution profile for a manifest download: `minimal`, `full`, or `archive` | `minimal` | +| `--chain` | Network: `arc-testnet`, `arc-devnet`, `arc-mainnet` | none | +| `--execution-url` | Explicit execution snapshot URL: `manifest.json` selects the manifest restore, anything else selects the archive restore | supply both URLs or resolve a pair from `--chain` | +| `--consensus-url` | Consensus `.tar.lz4` archive URL | supply both URLs or resolve both from `--chain` | | `--execution-path` | Directory for EL data | `~/.arc/execution` | | `--consensus-path` | Directory for CL data | `~/.arc/consensus` | +| `--force` | Replace both layers if their current data would otherwise block the restore | disabled | + +`--chain` is required whenever a snapshot URL is omitted, since it resolves the +latest complete entry from the API. It is also required whenever the +execution snapshot is a manifest, since it is passed to +`arc-node-execution` to select the chainspec. + +Listing tests use wiremock rather than the live service. The live response is +edge cached for five minutes and rate limited at roughly thirty requests in two +minutes, so it cannot provide deterministic fixtures or a reliable test loop. + +### Restore behavior + +Each layer records which snapshot it holds in a `.snapshot-url` marker file, +written only once the restore has finished. That marker, not the contents of the +directory, decides what a run does: + +| Target | Result | +|--------|--------| +| Empty | restore | +| Marker names the requested snapshot | nothing to do | +| Marker names a different snapshot | restore | +| Data present, no marker | **error** — pass `--force` to replace it | + +`--force` restores both layers whatever their markers say. It does not change +which snapshot the API resolver selects. + +The last row is an error rather than a skip or a restore because two states look +identical there, and they need opposite treatment. It may be a node that synced +from genesis or a validator that has been signing since `arc-node-consensus init` +— deleting that unasked is not something a snapshot can undo, and rewinding a +consensus store below the heights it has already voted at is worse than expensive. +Or it may be a restore that died before writing its marker, in which case the +files are part of a snapshot and skipping would report success over them. Nothing +on disk separates the two, so the run stops and the operator decides with +`--force`. + +Rows three and four differ for the same reason. A marker means the tool wrote +what is there and knows exactly what it is, so replacing one snapshot with +another costs only the download. + +Two details about what the marker records. + +A URL's signature is not part of it, so a re-signed pre-signed URL +does not read as a new snapshot. Every other query parameter is kept, and sorted +so their order does not matter. That is deliberate: a parameter like +`?network=arc-devnet` is part of what the URL addresses, and dropping it would +give two chains' snapshots one identity, so restoring either would report the +other as up to date. An unrecognised parameter is kept for the same reason — a +needless re-download is recoverable, a datadir holding the wrong chain is not. + +And for a manifest restore the execution marker records the `--el-profile` +alongside the URL, so re-running with a different profile counts as a different +snapshot and fetches the new component set. + +#### What a restore leaves behind + +Within one layer, a restore does not leave files from an older snapshot beside +the new one. The layers are restored in sequence, though, so an interrupted run +can leave them at different stages. Markers make that detectable rather than +self-healing: a layer left holding data with no marker is not overwritten by a restore +unless the operator passes `--force`. + +How a layer avoids mixing differs by layer, because their snapshots differ in +shape: + +- **Execution** — the directory is removed first. Extraction writes the files the + snapshot names and deletes nothing else, so anything the incoming snapshot does + not name survives: `static_files/` jars covering block ranges the restored + database has no checkpoints for, a `rocksdb/` left by an earlier + `--el-profile archive` restore, or a stale `reth.toml`. The last of those is the + worst, since it carries the pruning configuration and reth will not overwrite a + `reth.toml` that is already there. +- **Consensus** — the directory is not removed. Its snapshot is the single file + `store.db`, which extraction replaces outright, so there is nothing to clean + up. It is also the consensus node's home directory: removing it would take + `config/` and the validator's private key, which no snapshot restores. It would + also take `wal/consensus.wal`, and that file is wanted. Malachite wipes the WAL + whenever the height recorded in it differs from the height the node starts at, + which is what normally happens after a restore. When the two match — the node + had already started that height and died partway through it — the log is + replayed instead, and replaying it is what makes the node re-cast the vote it + cast before rather than a different one. The WAL is not a record of signatures + already sent; it is every message the node took in at that height, and feeding + those back rebuilds the state that produced the vote. + +That covers a crash, not a rewind. Restoring a validator to a snapshot below a +height it has already voted at gets no protection from the WAL, since a log +recorded at a *higher* height is discarded just as quietly as a stale one. The +error on unmarked data is what stops that restore: a node that has been running +has no marker, so the run refuses to touch it until an operator passes `--force` +and accepts the consequence. + +Because that directory survives, its marker is deleted before extraction starts +rather than left to be overwritten at the end. Otherwise a restore that failed +partway through `store.db` would leave a marker claiming the store is intact — and +when the snapshot being restored is the one already named there, as on a `--force` +retry, the next run would read a truncated store as up to date. + +The asymmetry also decides what a failed download costs. An archive is downloaded +in full before anything is touched, so a failure changes nothing. A manifest +restore has no staging step — `arc-node-execution` downloads straight into the +datadir — so once it starts, the previous execution data is gone whether or not it +finishes. A run interrupted there leaves data and no marker, which is the error +row above: the next run stops and asks for `--force`. diff --git a/crates/snapshots/src/download.rs b/crates/snapshots/src/download.rs index e3a25324..9d3398f0 100644 --- a/crates/snapshots/src/download.rs +++ b/crates/snapshots/src/download.rs @@ -35,7 +35,7 @@ use reqwest::{blocking::Client as BlockingClient, header::RANGE, Client, StatusC use serde::Deserialize; use tar::Archive; use tokio::task; -use tracing::info; +use tracing::{info, warn}; use url::Url; /// Base URL for the snapshot listing and download API. @@ -52,11 +52,13 @@ pub enum Chain { Testnet, #[value(name = "arc-devnet")] Devnet, + #[value(name = "arc-mainnet")] + Mainnet, } impl Chain { - /// Default execution data directory for this chain. - pub fn default_execution_path(self) -> Option { + /// Default execution data directory (same for all chains). + pub fn default_execution_path() -> Option { directories::BaseDirs::new().map(|dirs| dirs.home_dir().join(".arc").join("execution")) } @@ -64,6 +66,21 @@ impl Chain { pub fn default_consensus_path() -> Option { directories::BaseDirs::new().map(|dirs| dirs.home_dir().join(".arc").join("consensus")) } + + /// The chain name to pass to `arc-node-execution download --chain`, which + /// wants the `arc-` prefixed form. + /// + /// That binary decides which names it accepts, not this crate — the list is + /// `ArcChainSpecParser::SUPPORTED_CHAINS` in `arc-execution-config`. A rename + /// there would only show up when a manifest restore hands the name over, and by + /// then the datadir has been deleted. + pub fn arc_chain_arg(&self) -> &'static str { + match self { + Self::Testnet => "arc-testnet", + Self::Devnet => "arc-devnet", + Self::Mainnet => "arc-mainnet", + } + } } impl std::fmt::Display for Chain { @@ -71,92 +88,256 @@ impl std::fmt::Display for Chain { match self { Self::Testnet => write!(f, "testnet"), Self::Devnet => write!(f, "devnet"), + Self::Mainnet => write!(f, "mainnet"), } } } +/// One storage v2 publication record read by both automatic resolvers. +/// +/// Unlike a v1 entry, it covers both layers at one block under one prefix, so +/// callers do not cross-reference separate `layer` and top-level `key` fields by +/// block number. #[derive(Debug, Deserialize)] -struct SnapshotEntry { - key: String, +struct V2SnapshotEntry { network: String, - retention: String, - layer: String, #[serde(rename = "blockNumber")] block_number: u64, + execution: V2Execution, + /// The consensus half may still be uploading when the entry appears. + /// + /// `Option` without `serde(default)` accepts both an absent field and an + /// explicit null. The selector can then use an older complete entry instead + /// of failing to parse the whole listing. + consensus: Option, } -/// Fetch the latest pruned EL and CL snapshot URLs for the given chain from the snapshot API. +/// The execution half of a storage v2 publication read by the resolver. /// -/// Returns `(execution_url, consensus_url)`. -pub async fn fetch_latest_snapshot_urls(chain: Chain) -> Result<(String, String)> { - fetch_latest_snapshot_urls_from(chain, SNAPSHOT_API_BASE_URL).await +/// The manifest value is an object key routed through `{base}/download/{key}`, +/// not a presigned URL. The resulting URL must remain query-free because reth +/// pops its last path segment and `Url::as_str` keeps the query when serializing +/// each component URL. A retained query would send every component request to a +/// 404. `components` is deliberately unmodeled because this resolver neither +/// selects components nor checks their disk requirements. +#[derive(Debug, Deserialize)] +struct V2Execution { + #[serde(rename = "manifestKey")] + manifest_key: String, } -async fn fetch_latest_snapshot_urls_from(chain: Chain, base_url: &str) -> Result<(String, String)> { - let listing_url = format!("{}/snapshots?network={}", base_url, chain); +/// The consensus half of a storage v2 publication used by the archive restore. +/// +/// Storage v2 still publishes `consensus.tar.lz4`, so the lz4 and tar extraction +/// path remains required. Every such key has the same last path segment, which +/// is why [`resumable_download`] binds partial bytes to their full URL. +#[derive(Debug, Deserialize)] +struct V2Consensus { + key: String, +} + +/// A storage v2 record already proven to contain both layers. +/// +/// Callers receive consensus as a required value and do not repeat the upload +/// completeness check performed by [`select_latest_v2_snapshot`]. +#[derive(Debug)] +struct SelectedV2Snapshot { + block_number: u64, + execution: V2Execution, + consensus: V2Consensus, +} - #[derive(Deserialize)] - struct SnapshotListResponse { - snapshots: Vec, +/// The storage v2 part of the listing response read by automatic resolution. +/// +/// The v1 `snapshots` array is deliberately undeclared. Serde accepts and drops +/// undeclared fields, which is intentional for v1 but caused the original bug +/// when `v2Snapshots` was undeclared. This field has no `serde(default)` because +/// deployments with storage v2 disabled omit it, and the resulting error must +/// name the field instead of claiming the publisher shipped no entries. +#[derive(Debug, Deserialize)] +struct SnapshotListResponse { + #[serde(rename = "v2Snapshots")] + v2_snapshots: Vec, +} + +/// The execution-layer artifact to restore, and the download style it implies. +#[derive(Debug, PartialEq)] +pub enum ExecutionSnapshotSource { + /// A reth manifest (`manifest.json`), downloaded by handing off to + /// `arc-node-execution download`. + Manifest(String), + /// A single `.tar.lz4` archive, restored by arc-snapshots itself. + Archive(String), +} + +impl ExecutionSnapshotSource { + /// Classifies a URL as a manifest or a single archive. + pub fn from_url(url: String) -> Self { + if is_manifest_url(&url) { + Self::Manifest(url) + } else { + Self::Archive(url) + } } +} - let response: SnapshotListResponse = Client::new() - .get(&listing_url) - .send() - .await? - .error_for_status()? - .json() - .await?; - // FIXME: the API returns snapshots for all networks regardless of ?network=; filter manually - // until server-side filtering is fixed. - let network = chain.to_string(); - let entries: Vec<_> = response - .snapshots - .into_iter() - .filter(|e| e.network == network) - .collect(); +/// Everything in `url` before the query string or fragment. +fn url_path(url: &str) -> &str { + url.split_once(['?', '#']).map_or(url, |(path, _)| path) +} - let el_entries: Vec<_> = entries - .iter() - .filter(|e| e.layer == "execution" && e.retention == "pruned") - .collect(); - let cl_entries: Vec<_> = entries +/// Query parameters a signer regenerates on every resolution, by name prefix. +const SIGNATURE_PARAM_PREFIXES: [&str; 2] = ["x-amz-", "x-goog-"]; + +/// Query parameters a pre-SigV4 signer regenerates, by exact name. +const SIGNATURE_PARAM_NAMES: [&str; 3] = ["signature", "expires", "awsaccesskeyid"]; + +/// Whether `param` is `name=value` for something a signer rewrites each time. +fn is_signature_param(param: &str) -> bool { + let name = param + .split_once('=') + .map_or(param, |(name, _)| name) + .to_ascii_lowercase(); + SIGNATURE_PARAM_PREFIXES .iter() - .filter(|e| e.layer == "consensus" && e.retention == "pruned") + .any(|prefix| name.starts_with(prefix)) + || SIGNATURE_PARAM_NAMES.contains(&name.as_str()) +} + +/// Turns a URL into a name for the snapshot it points at. +/// +/// The name is written to `.snapshot-url` after a restore. A later run builds the +/// name of the snapshot it is about to download and compares the two to see +/// whether the work is already done. So the same snapshot must always produce the +/// same name, and different snapshots must never produce the same one. +/// +/// The signature is removed, because a pre-signed URL gets a fresh one every time +/// it is handed out and the same snapshot would otherwise look new on every run. +/// The rest of the query string is kept: something like `?network=arc-devnet` says +/// which snapshot this is, and removing it would give devnet and testnet the same +/// name, so restoring one would look like it had already restored the other. +/// +/// Parameters are sorted, so listing them in a different order still gives the +/// same name. +pub fn url_identity(url: &str) -> String { + let path = url_path(url); + let Some((_, query)) = url.split_once('?') else { + return path.to_string(); + }; + let mut kept: Vec<&str> = query + .split_once('#') + .map_or(query, |(query, _)| query) + .split('&') + .filter(|param| !param.is_empty() && !is_signature_param(param)) .collect(); - if el_entries.is_empty() { - eyre::bail!("no pruned execution snapshot found for {chain}"); - } - if cl_entries.is_empty() { - eyre::bail!("no pruned consensus snapshot found for {chain}"); + if kept.is_empty() { + return path.to_string(); } + kept.sort_unstable(); + format!("{path}?{}", kept.join("&")) +} - // EL and CL snapshots are produced on independent schedules, so the latest of each may - // not share the same block height. Find the highest block that has both. - let el_blocks: std::collections::HashSet = - el_entries.iter().map(|e| e.block_number).collect(); - let cl_blocks: std::collections::HashSet = - cl_entries.iter().map(|e| e.block_number).collect(); - let common_block = el_blocks - .intersection(&cl_blocks) - .max() - .copied() - .ok_or_else(|| eyre::eyre!("no matching EL+CL pruned snapshot pair found for {chain}"))?; +/// Whether a URL points at a reth snapshot manifest rather than a single +/// archive. +/// +/// Reads the path, not the [`url_identity`]: the identity may carry query +/// parameters, and the last path segment is what names the artifact. +fn is_manifest_url(url: &str) -> bool { + url_path(url).rsplit('/').next() == Some("manifest.json") +} - let latest_el = el_entries - .iter() - .find(|e| e.block_number == common_block) - .ok_or_else(|| eyre::eyre!("internal: no EL entry for common block {common_block}"))?; - let latest_cl = cl_entries - .iter() - .find(|e| e.block_number == common_block) - .ok_or_else(|| eyre::eyre!("internal: no CL entry for common block {common_block}"))?; +/// Fetches the consensus snapshot URL from the latest complete storage v2 entry. +/// +/// Using the paired publication record keeps standalone consensus resolution on +/// the same block that automatic execution resolution would select. +pub async fn fetch_latest_consensus_url(chain: Chain) -> Result { + fetch_latest_consensus_url_from(chain, SNAPSHOT_API_BASE_URL).await +} + +/// Resolves the consensus download URL from the latest complete storage v2 +/// entry at `base_url`. Split from +/// [`fetch_latest_consensus_url`] so tests can inject a mock server URL. +async fn fetch_latest_consensus_url_from(chain: Chain, base_url: &str) -> Result { + let selected = select_latest_v2_snapshot(chain, base_url).await?; + Ok(format!("{}/download/{}", base_url, selected.consensus.key)) +} + +/// Resolve the execution and consensus snapshot sources for the given chain. +/// +/// Storage v2 publishes both layers as one entry, so selecting one record keeps +/// their block heights aligned without cross-referencing separate artifacts. +pub async fn resolve_snapshot_sources(chain: Chain) -> Result<(ExecutionSnapshotSource, String)> { + resolve_snapshot_sources_from(chain, SNAPSHOT_API_BASE_URL).await +} + +/// Resolves snapshot sources from the API at `base_url`. Split from +/// [`resolve_snapshot_sources`] so tests can inject a mock server URL. +async fn resolve_snapshot_sources_from( + chain: Chain, + base_url: &str, +) -> Result<(ExecutionSnapshotSource, String)> { + let selected = select_latest_v2_snapshot(chain, base_url).await?; + + info!( + block = selected.block_number, + "Selected storage v2 snapshot" + ); + + let execution = ExecutionSnapshotSource::Manifest(format!( + "{}/download/{}", + base_url, selected.execution.manifest_key + )); + let consensus_url = format!("{}/download/{}", base_url, selected.consensus.key); + Ok((execution, consensus_url)) +} + +/// Returns the newest complete storage v2 entry for both automatic resolvers. +/// +/// Both callers require consensus, so an incomplete upload must not hide an +/// older usable entry. Selecting completeness before block height preserves +/// availability while keeping both layers on one publication record. +async fn select_latest_v2_snapshot(chain: Chain, base_url: &str) -> Result { + fetch_v2_snapshot_entries(chain, base_url) + .await? + .into_iter() + .filter_map(|entry| { + entry.consensus.map(|consensus| SelectedV2Snapshot { + block_number: entry.block_number, + execution: entry.execution, + consensus, + }) + }) + .max_by_key(|entry| entry.block_number) + .ok_or_else(|| { + eyre::eyre!( + "no complete storage v2 snapshot found for {chain}; \ + this deployment may not publish storage v2" + ) + }) +} - let execution_url = format!("{}/download/{}", base_url, latest_el.key); - let consensus_url = format!("{}/download/{}", base_url, latest_cl.key); +/// Fetches and network-filters the storage v2 entries listed for `chain`. +/// +/// The server honors the case-sensitive query and [`Chain`] renders its bare +/// network name. The client filter remains as defense in depth so a server +/// regression cannot silently select another network's snapshot. +async fn fetch_v2_snapshot_entries(chain: Chain, base_url: &str) -> Result> { + let listing_url = format!("{}/snapshots?network={}", base_url, chain); - Ok((execution_url, consensus_url)) + let response = Client::new() + .get(&listing_url) + .send() + .await? + .error_for_status()?; + let body = response.bytes().await?; + let response: SnapshotListResponse = serde_json::from_slice(&body)?; + let network = chain.to_string(); + Ok(response + .v2_snapshots + .into_iter() + .filter(|e| e.network == network) + .collect()) } struct DownloadProgress { @@ -282,6 +463,36 @@ fn open_part_file(part_path: &Path, append: bool) -> Result { } } +/// Ensures retry logic can append only to bytes downloaded from `url`. +/// +/// A stale part is deleted before its marker changes. If the marker changed first +/// and the process stopped before deletion, a later run could append to stale bytes. +/// This ordering keeps the ownership transition safe across process termination. +fn prepare_partial_download(url: &str, part_path: &Path, marker_path: &Path) -> Result<()> { + let identity = url_identity(url); + if matches!(std::fs::read_to_string(marker_path), Ok(saved) if saved == identity) { + return Ok(()); + } + + match std::fs::remove_file(part_path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(eyre::eyre!( + "Failed to remove stale part file {}: {error}", + part_path.display() + )) + } + } + + std::fs::write(marker_path, identity).map_err(|error| { + eyre::eyre!( + "Failed to write partial download marker {}: {error}", + marker_path.display() + ) + }) +} + /// Performs a single download attempt, appending to `part_path` if the server honours the /// Range request. Returns the total file size reported by the server. fn attempt_download(client: &BlockingClient, url: &str, part_path: &Path) -> Result { @@ -322,6 +533,9 @@ fn resumable_download(url: &str, target_dir: &Path) -> Result<(PathBuf, u64)> { let file_name = file_name_from_url(url); let final_path = target_dir.join(&file_name); let part_path = target_dir.join(format!("{file_name}.part")); + let marker_path = target_dir.join(format!("{file_name}.part.url")); + + prepare_partial_download(url, &part_path, &marker_path)?; let client = BlockingClient::builder() .connect_timeout(Duration::from_secs(30)) @@ -340,6 +554,13 @@ fn resumable_download(url: &str, target_dir: &Path) -> Result<(PathBuf, u64)> { match attempt_download(&client, url, &part_path) { Ok(total) => { std::fs::rename(&part_path, &final_path)?; + if let Err(error) = std::fs::remove_file(&marker_path) { + warn!( + marker = %marker_path.display(), + %error, + "Failed to remove partial download marker after promotion" + ); + } info!("Download complete: {}", final_path.display()); return Ok((final_path, total)); } @@ -412,31 +633,32 @@ fn extract_archive(archive_path: &Path, dest_dir: &Path) -> Result<()> { Ok(()) } -/// Downloads `url` and extracts it into `dest_dir`. Uses `tmp_dir` as a staging area. -/// Removes `tmp_dir` on success; removes it on extraction failure so a re-run starts fresh. -pub fn download_and_extract(url: &str, dest_dir: &Path, tmp_dir: &Path) -> Result<()> { +/// Downloads the archive at `url` into `tmp_dir` and returns where it landed. +/// +/// Creates `tmp_dir` itself, and when the download fails it leaves behind what it +/// managed to fetch as a `.part` file. A later request for the same snapshot +/// continues from there instead of transferring tens of gigabytes again. +/// +/// That is why callers delete `tmp_dir` only once this has returned successfully. A +/// download failure is supposed to leave it alone; a failure while unpacking is +/// not, because a broken archive is not worth resuming. +fn download_archive(url: &str, tmp_dir: &Path) -> Result { info!(url, "Downloading snapshot"); let (archive_path, _total_size) = resumable_download(url, tmp_dir)?; - - info!("Extracting snapshot"); - let extract_result = extract_archive(&archive_path, dest_dir); - if let Err(e) = extract_result { - // Remove tmp_dir so a re-run downloads and extracts from scratch. - let _ = std::fs::remove_dir_all(tmp_dir); - return Err(e); - } - - std::fs::remove_dir_all(tmp_dir)?; - info!("Removed snapshot staging directory"); - Ok(()) + Ok(archive_path) } -/// Async wrapper: runs download+extract on a blocking thread. -pub async fn stream_and_extract(url: String, dest_dir: PathBuf, tmp_dir: PathBuf) -> Result<()> { - task::spawn_blocking(move || download_and_extract(&url, &dest_dir, &tmp_dir)).await? +fn extract_downloaded_archive(archive_path: &Path, dest_dir: &Path) -> Result<()> { + info!("Extracting snapshot"); + extract_archive(archive_path, dest_dir) } -fn execution_snapshot_exists(dir: &Path) -> bool { +/// Whether `dir` already holds execution data. +/// +/// `db/mdbx.dat` is the state database, written by both restore styles: the +/// lz4 archive path extracts it from the EL archive, and a manifest restore gets it +/// from the `State` component. +pub fn execution_snapshot_exists(dir: &Path) -> bool { dir.join("db/mdbx.dat").exists() } @@ -445,42 +667,204 @@ pub fn consensus_snapshot_exists(dir: &Path) -> bool { } const SNAPSHOT_VERSION_FILE: &str = ".snapshot-url"; +const EXECUTION_STAGING_DIR: &str = "execution"; +const CONSENSUS_STAGING_DIR: &str = "consensus"; +/// Records which snapshot `dir` now holds. +/// +/// Written only once a restore has finished, so the marker's presence means the +/// directory holds that snapshot, complete. [`should_download`] relies on that: +/// anything else with data in it is not this tool's to replace. +/// +/// Stores [`url_identity`] rather than `url`, so the marker survives a re-signed +/// pre-signed URL and no signature is left on disk. [`should_download`] +/// normalizes the same way, so the two always compare like with like. pub fn write_snapshot_version(dir: &Path, url: &str) -> Result<()> { - std::fs::write(dir.join(SNAPSHOT_VERSION_FILE), url)?; + std::fs::write(dir.join(SNAPSHOT_VERSION_FILE), url_identity(url))?; Ok(()) } -/// Returns `true` if the layer should be downloaded, `false` if it should be skipped. -pub fn should_download(layer: &str, dir: &Path, url: &str, exists: bool, force: bool) -> bool { - if force { - return true; +/// Drops the marker in `dir` before a restore invalidates what it describes. +/// +/// A marker claims the directory holds a complete snapshot. A restore about to +/// overwrite that snapshot has to withdraw the claim first, or a failure partway +/// leaves the old marker beside the wreckage — and when the snapshot being +/// restored is the one the marker already names, as on a `--force` retry, the +/// next run reads it as up to date. +/// +/// Only needed where a restore writes into a directory it does not remove. The +/// execution layer removes its datadir, which takes the marker along. +fn invalidate_snapshot_version(dir: &Path) -> Result<()> { + match std::fs::remove_file(dir.join(SNAPSHOT_VERSION_FILE)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(eyre::eyre!( + "failed to clear the snapshot marker in {}: {e}", + dir.display() + )), } - if !exists { - return true; +} + +/// Whether the layer in `dir` needs restoring from `url`, or the run must stop. +/// +/// The marker decides, not the directory contents: +/// +/// - no data, or `force` — restore. +/// - marker names `url` — nothing to do. +/// - marker names another snapshot — restore. What is there is a snapshot this +/// tool wrote, so replacing it costs only the download. +/// - data with no marker — an error. It may be a node that synced from genesis +/// or a validator that has been signing since `arc-node-consensus init`, and +/// the tool cannot tell that from a restore that died before writing its +/// marker. Deleting either without being asked is worse than stopping, and +/// skipping would report success over what may be half a snapshot. `--force` +/// is how an operator says which it is. +/// +/// `url` is compared as [`url_identity`], matching what +/// [`write_snapshot_version`] stored. +pub fn should_download( + layer: &str, + dir: &Path, + url: &str, + exists: bool, + force: bool, +) -> Result { + if force || !exists { + return Ok(true); } match std::fs::read_to_string(dir.join(SNAPSHOT_VERSION_FILE)) { - Ok(saved) if saved.trim() == url => { + Ok(saved) if saved.trim() == url_identity(url) => { info!(dir = %dir.display(), "{layer} data already exists and is up to date, skipping download"); - false + Ok(false) } Ok(_) => { info!(dir = %dir.display(), "Newer {layer} snapshot available, re-downloading"); - true - } - Err(_) => { - // No marker file — data from an older tool version or manual placement. Don't clobber. - info!(dir = %dir.display(), "{layer} data already exists but version is unknown, skipping download (use --force to re-download)"); - false + Ok(true) } + Err(_) => eyre::bail!( + "{} holds data in {} that no snapshot restore recorded; pass --force to replace it", + layer, + dir.display() + ), + } +} + +/// Groups the inputs that must move together for a clean pair restore. +/// +/// Force restore treats EL and CL as one snapshot pair: both archives must be on +/// disk before either target is touched. +struct SnapshotPair<'a> { + el_url: &'a str, + cl_url: &'a str, + execution_dir: &'a Path, + consensus_dir: &'a Path, + tmp_dir: &'a Path, +} + +/// Removes an existing restore target and treats a missing directory as clean. +/// +/// Forced restore recreates targets from the downloaded archives, so stale +/// files must not survive. Missing directories are acceptable because a fresh +/// restore may be starting from an empty data path. +pub(crate) fn remove_restore_dir(dir: &Path) -> Result<()> { + match std::fs::remove_dir_all(dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(eyre::eyre!( + "failed to remove snapshot target {}: {e}", + dir.display() + )), + } +} + +/// Restores EL and CL as a fresh snapshot pair. +/// +/// Downloads both archives into staging before touching either target, so a +/// failed second download cannot purge data the first one replaced. Only the +/// execution directory is then removed; see [`restore_consensus`] for why the +/// consensus one is not. +fn force_download_and_extract_both(pair: SnapshotPair<'_>) -> Result<()> { + let el_tmp_dir = pair.tmp_dir.join(EXECUTION_STAGING_DIR); + let cl_tmp_dir = pair.tmp_dir.join(CONSENSUS_STAGING_DIR); + + let el_archive = download_archive(pair.el_url, &el_tmp_dir)?; + let cl_archive = download_archive(pair.cl_url, &cl_tmp_dir)?; + + // Only the execution directory. The consensus directory is a malachite home + // and holds files no snapshot restores; see `restore_consensus`. + remove_restore_dir(pair.execution_dir)?; + + let extract_result = (|| { + extract_downloaded_archive(&el_archive, pair.execution_dir)?; + write_snapshot_version(pair.execution_dir, pair.el_url)?; + extract_consensus_store(&cl_archive, pair.consensus_dir, pair.cl_url) + })(); + + if let Err(e) = extract_result { + let _ = std::fs::remove_dir_all(pair.tmp_dir); + return Err(e); } + + std::fs::remove_dir_all(pair.tmp_dir)?; + info!("Removed snapshot staging directory"); + Ok(()) +} + +/// Restores the execution layer from an archive, replacing what was there. +/// +/// Extraction writes the files the archive names and deletes nothing else, so +/// unpacking onto a datadir another restore left behind keeps whatever the new +/// archive does not name: `static_files/` jars covering block ranges the restored +/// database has no checkpoints for, a `rocksdb/` from an earlier archive-profile +/// manifest restore, or a `reth.toml`. The target is removed first, but only +/// after the archive is staged, so a failed download costs nothing. +/// +/// Nothing in the execution directory belongs to the operator, which is what +/// makes removing it safe; the consensus directory is a malachite home and is not +/// treated this way. +fn replace_from_archive(url: &str, execution_dir: &Path, staging_dir: &Path) -> Result<()> { + let archive_path = download_archive(url, staging_dir)?; + remove_restore_dir(execution_dir)?; + + let result = extract_downloaded_archive(&archive_path, execution_dir) + .and_then(|()| write_snapshot_version(execution_dir, url)); + + let _ = std::fs::remove_dir_all(staging_dir); + result +} + +/// Unpacks a staged consensus archive over the store in `consensus_dir`. +/// +/// The one place the consensus store is written, so the marker rule is stated +/// once. The directory is not removed — [`restore_consensus`] carries why — so its +/// marker is withdrawn before extraction starts. Left in place, a failure partway +/// through `store.db` would leave a marker claiming the store is intact. +fn extract_consensus_store(archive_path: &Path, consensus_dir: &Path, url: &str) -> Result<()> { + invalidate_snapshot_version(consensus_dir)?; + extract_downloaded_archive(archive_path, consensus_dir)?; + write_snapshot_version(consensus_dir, url) +} + +/// Downloads the consensus archive at `url` and unpacks it over the store. +/// +/// Staging happens before anything is written, so a failed download changes +/// nothing at all. +fn replace_consensus_store(url: &str, consensus_dir: &Path, staging_dir: &Path) -> Result<()> { + let archive_path = download_archive(url, staging_dir)?; + let result = extract_consensus_store(&archive_path, consensus_dir, url); + let _ = std::fs::remove_dir_all(staging_dir); + result } -/// Downloads and extracts both EL and CL archives sequentially. -/// EL is extracted into `execution_dir`, CL into `consensus_dir`. -/// Uses `tmp_dir/el` and `tmp_dir/cl` as staging areas. -/// Skips a layer if its destination already contains up-to-date snapshot data, -/// unless `force_redownload` is true. +/// Downloads and extracts both EL and CL archives. +/// +/// Each layer is restored only if [`should_download`] says so. A forced restore +/// stages both archives before touching either target, so the pair moves +/// together. Either way the execution directory is removed before extraction and +/// the consensus directory is not; [`restore_consensus`] carries the reason for +/// the asymmetry. +/// +/// Uses `tmp_dir/execution` and `tmp_dir/consensus` as staging areas. pub fn download_and_extract_both( el_url: &str, cl_url: &str, @@ -489,33 +873,110 @@ pub fn download_and_extract_both( tmp_dir: &Path, force_redownload: bool, ) -> Result<()> { - if should_download( + if force_redownload { + return force_download_and_extract_both(SnapshotPair { + el_url, + cl_url, + execution_dir, + consensus_dir, + tmp_dir, + }); + } + + // Both decisions before either restore: one layer refusing must not leave the + // other already replaced. + let restore_execution = should_download( "Execution layer", execution_dir, el_url, execution_snapshot_exists(execution_dir), force_redownload, - ) { - download_and_extract(el_url, execution_dir, &tmp_dir.join("el"))?; - write_snapshot_version(execution_dir, el_url)?; - } - - if should_download( + )?; + let restore_consensus = should_download( "Consensus layer", consensus_dir, cl_url, consensus_snapshot_exists(consensus_dir), force_redownload, - ) { - download_and_extract(cl_url, consensus_dir, &tmp_dir.join("cl"))?; - write_snapshot_version(consensus_dir, cl_url)?; + )?; + + if restore_execution { + replace_from_archive(el_url, execution_dir, &tmp_dir.join(EXECUTION_STAGING_DIR))?; + } + if restore_consensus { + replace_consensus_store(cl_url, consensus_dir, &tmp_dir.join(CONSENSUS_STAGING_DIR))?; } - // Both subdirs are cleaned up by download_and_extract; remove the parent if empty. + // Each restore removes its own staging subdir; remove the parent if empty. let _ = std::fs::remove_dir(tmp_dir); Ok(()) } +/// Restores the consensus layer from a single `.tar.lz4` archive. +/// +/// Restores only when [`should_download`] says so. The archive is staged before +/// anything is written, so a failed download leaves the existing store alone. +/// +/// The target directory is never deleted, not even under `--force`. It is the +/// malachite home, and it holds two files no snapshot puts back: +/// `config/priv_validator_key.json` and `wal/consensus.wal`. Deleting it would +/// buy nothing anyway — the archive contains only `store.db`, and extraction +/// truncates that file, so the store is fully replaced either way. What the +/// surviving directory does require is that the marker be deleted before +/// extraction rather than overwritten after it. A consensus archive that grows +/// beyond `store.db` would need this revisited. +/// +/// Keeping the WAL is a safety decision. Every time malachite starts a height it +/// compares the height recorded in that file against the one it is about to run. +/// They differ in the ordinary case and the log is wiped, so a WAL left from +/// before the restore usually goes away on its own. They match only when the node +/// had already started that height and died partway through it, and then the log +/// is replayed. +/// +/// Replay is the reason to keep it. The log is not a list of votes this node +/// signed; it is everything the node took in at that height, in arrival order — +/// votes and proposals from any validator, proposed values, elapsed timeouts, +/// polka certificates. Feeding that sequence back rebuilds the state the node +/// was in, so it signs the same vote as before instead of a conflicting one. +/// Delete the WAL and the node comes back to that height knowing nothing, free +/// to vote for something else. +/// +/// That covers a crash, not a rewind. A snapshot that puts the node below a +/// height it has already voted at gets no help from the WAL, because malachite +/// discards a log recorded at a higher height exactly as quietly as a stale one. +/// What stops that restore is [`should_download`] refusing to touch data no +/// restore recorded until the operator passes `--force`. +pub fn restore_consensus( + url: &str, + consensus_dir: &Path, + tmp_dir: &Path, + force: bool, +) -> Result<()> { + if !should_download( + "Consensus layer", + consensus_dir, + url, + consensus_snapshot_exists(consensus_dir), + force, + )? { + return Ok(()); + } + + let result = replace_consensus_store(url, consensus_dir, &tmp_dir.join(CONSENSUS_STAGING_DIR)); + let _ = std::fs::remove_dir(tmp_dir); + result +} + +/// Async wrapper: runs the consensus restore on a blocking thread. +pub async fn stream_restore_consensus( + url: String, + consensus_dir: PathBuf, + tmp_dir: PathBuf, + force: bool, +) -> Result<()> { + task::spawn_blocking(move || restore_consensus(&url, &consensus_dir, &tmp_dir, force)).await? +} + /// Async wrapper: runs the combined EL+CL download+extract on a single blocking thread. pub async fn stream_and_extract_both( el_url: String, @@ -563,6 +1024,37 @@ mod tests { Ok(buf) } + /// Builds an archive that unpacks `path` and then fails. + /// + /// The trailing symlink entry is rejected mid-stream, so extraction leaves + /// the first file on disk — the shape a restore killed partway has, and the + /// only one where the target ends up holding part of a snapshot. + fn build_tar_lz4_failing_after(path: &str, content: &[u8]) -> Result> { + let buf = Vec::new(); + let encoder = lz4::EncoderBuilder::new().build(buf)?; + let mut builder = tar::Builder::new(encoder); + + let mut good = tar::Header::new_gnu(); + good.set_size(content.len() as u64); + good.set_mode(0o644); + good.set_cksum(); + builder.append_data(&mut good, path, content)?; + + let mut link = tar::Header::new_gnu(); + link.set_entry_type(tar::EntryType::Symlink); + link.set_size(0); + link.set_mode(0o777); + let gnu = link.as_gnu_mut().expect("gnu header"); + gnu.linkname[..b"/etc\0".len()].copy_from_slice(b"/etc\0"); + gnu.name[..b"link\0".len()].copy_from_slice(b"link\0"); + link.set_cksum(); + builder.append(&link, b"".as_ref())?; + + let (buf, result) = builder.into_inner()?.finish(); + result?; + Ok(buf) + } + /// Write `data` to `/` and return the path. fn write_file(dir: &std::path::Path, name: &str, data: &[u8]) -> PathBuf { let path = dir.join(name); @@ -570,66 +1062,622 @@ mod tests { path } - // --------------------------------------------------------------------------- - // Chain - // --------------------------------------------------------------------------- + fn seed_partial_download( + target_dir: &Path, + url: &str, + data: &[u8], + ) -> Result<(PathBuf, PathBuf)> { + std::fs::create_dir_all(target_dir)?; + let file_name = file_name_from_url(url); + let part_path = target_dir.join(format!("{file_name}.part")); + let marker_path = target_dir.join(format!("{file_name}.part.url")); + std::fs::write(&part_path, data)?; + std::fs::write(&marker_path, url_identity(url))?; + Ok((part_path, marker_path)) + } - #[test] - fn chain_display() { - assert_eq!(Chain::Testnet.to_string(), "testnet"); - assert_eq!(Chain::Devnet.to_string(), "devnet"); + async fn run_resumable_download(url: String, target_dir: PathBuf) -> Result<(PathBuf, u64)> { + tokio::task::spawn_blocking(move || resumable_download(&url, &target_dir)).await? } - #[test] - fn chain_default_execution_path_ends_with_arc_execution() { - // BaseDirs resolves on any OS with a home dir; in CI HOME is always set. - if let Some(p) = Chain::Testnet.default_execution_path() { - assert!(p.ends_with(".arc/execution")); - } + async fn mount_full_download(server: &wiremock::MockServer, request_path: &str, body: &[u8]) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + + Mock::given(method("GET")) + .and(path(request_path)) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(body.to_vec()) + .append_header("Content-Length", body.len().to_string().as_str()), + ) + .expect(1) + .mount(server) + .await; } - #[test] - fn chain_default_consensus_path_ends_with_arc_consensus() { - if let Some(p) = Chain::default_consensus_path() { - assert!(p.ends_with(".arc/consensus")); + async fn request_range(server: &wiremock::MockServer) -> Result> { + let requests = server + .received_requests() + .await + .ok_or_else(|| eyre::eyre!("Request recording is disabled"))?; + if requests.len() != 1 { + return Err(eyre::eyre!( + "Expected one request, received {}", + requests.len() + )); } + requests[0] + .headers + .get("range") + .map(|value| value.to_str().map(str::to_string).map_err(Into::into)) + .transpose() } - // --------------------------------------------------------------------------- - // DownloadProgress helpers - // --------------------------------------------------------------------------- + #[tokio::test] + async fn resumable_download_resumes_the_same_url() -> Result<()> { + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; - #[test] - fn format_size_bytes() { - assert_eq!(DownloadProgress::format_size(0), "0.00 B"); - assert_eq!(DownloadProgress::format_size(512), "512.00 B"); - } + let server = MockServer::start().await; + let url = format!("{}/snap/consensus.tar.lz4", server.uri()); + let dir = tempfile::tempdir()?; + seed_partial_download(dir.path(), &url, b"prefix-")?; + Mock::given(method("GET")) + .and(path("/snap/consensus.tar.lz4")) + .and(header("range", "bytes=7-")) + .respond_with( + ResponseTemplate::new(206) + .set_body_bytes(b"rest".to_vec()) + .append_header("Content-Range", "bytes 7-10/11"), + ) + .expect(1) + .mount(&server) + .await; - #[test] - fn format_size_kilobytes() { - assert_eq!(DownloadProgress::format_size(1024), "1.00 KB"); - assert_eq!(DownloadProgress::format_size(2048), "2.00 KB"); - } + let (path, total) = run_resumable_download(url, dir.path().to_path_buf()).await?; - #[test] - fn format_size_megabytes() { - assert_eq!(DownloadProgress::format_size(1024 * 1024), "1.00 MB"); + assert_eq!(std::fs::read(path)?, b"prefix-rest"); + assert_eq!(total, 11); + Ok(()) } - #[test] - fn format_size_gigabytes() { - assert_eq!(DownloadProgress::format_size(1024 * 1024 * 1024), "1.00 GB"); - } + #[tokio::test] + async fn resumable_download_resumes_with_a_refreshed_signature() -> Result<()> { + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; - #[test] - fn format_duration_seconds() { - assert_eq!( - DownloadProgress::format_duration(Duration::from_secs(45)), - "45s" - ); + let server = MockServer::start().await; + let bare_url = format!("{}/snap/consensus.tar.lz4", server.uri()); + let old_url = format!("{bare_url}?X-Amz-Signature=old"); + let new_url = format!("{bare_url}?X-Amz-Signature=new"); + let dir = tempfile::tempdir()?; + seed_partial_download(dir.path(), &old_url, b"prefix-")?; + Mock::given(method("GET")) + .and(path("/snap/consensus.tar.lz4")) + .and(header("range", "bytes=7-")) + .respond_with( + ResponseTemplate::new(206) + .set_body_bytes(b"rest".to_vec()) + .append_header("Content-Range", "bytes 7-10/11"), + ) + .expect(1) + .mount(&server) + .await; + + let (path, _) = run_resumable_download(new_url, dir.path().to_path_buf()).await?; + + assert_eq!(std::fs::read(path)?, b"prefix-rest"); + Ok(()) } - #[test] + #[tokio::test] + async fn resumable_download_discards_a_different_path_with_the_same_name() -> Result<()> { + let server = wiremock::MockServer::start().await; + let old_url = format!("{}/block-a/consensus.tar.lz4", server.uri()); + let new_url = format!("{}/block-b/consensus.tar.lz4", server.uri()); + let dir = tempfile::tempdir()?; + seed_partial_download(dir.path(), &old_url, b"block-a-prefix")?; + mount_full_download(&server, "/block-b/consensus.tar.lz4", b"block-b-archive").await; + + let (path, _) = run_resumable_download(new_url, dir.path().to_path_buf()).await?; + + assert_eq!(request_range(&server).await?, None); + assert_eq!(std::fs::read(path)?, b"block-b-archive"); + Ok(()) + } + + #[tokio::test] + async fn resumable_download_discards_the_same_path_from_another_host() -> Result<()> { + let server = wiremock::MockServer::start().await; + let path = "/block/consensus.tar.lz4"; + let old_url = format!("http://snapshot.example{path}"); + let new_url = format!("{}{path}", server.uri()); + let dir = tempfile::tempdir()?; + seed_partial_download(dir.path(), &old_url, b"other-host")?; + mount_full_download(&server, path, b"current-host").await; + + let (downloaded_path, _) = + run_resumable_download(new_url, dir.path().to_path_buf()).await?; + + assert_eq!(request_range(&server).await?, None); + assert_eq!(std::fs::read(downloaded_path)?, b"current-host"); + Ok(()) + } + + #[tokio::test] + async fn resumable_download_discards_a_non_signature_query_change() -> Result<()> { + let server = wiremock::MockServer::start().await; + let bare_url = format!("{}/consensus.tar.lz4", server.uri()); + let old_url = format!("{bare_url}?network=devnet"); + let new_url = format!("{bare_url}?network=testnet"); + let dir = tempfile::tempdir()?; + seed_partial_download(dir.path(), &old_url, b"devnet")?; + mount_full_download(&server, "/consensus.tar.lz4", b"testnet").await; + + let (path, _) = run_resumable_download(new_url, dir.path().to_path_buf()).await?; + + assert_eq!(request_range(&server).await?, None); + assert_eq!(std::fs::read(path)?, b"testnet"); + Ok(()) + } + + #[tokio::test] + async fn resumable_download_discards_a_part_without_a_marker() -> Result<()> { + let server = wiremock::MockServer::start().await; + let url = format!("{}/consensus.tar.lz4", server.uri()); + let dir = tempfile::tempdir()?; + let (_, marker_path) = seed_partial_download(dir.path(), &url, b"unowned")?; + std::fs::remove_file(marker_path)?; + mount_full_download(&server, "/consensus.tar.lz4", b"fresh").await; + + let (path, _) = run_resumable_download(url, dir.path().to_path_buf()).await?; + + assert_eq!(request_range(&server).await?, None); + assert_eq!(std::fs::read(path)?, b"fresh"); + Ok(()) + } + + #[tokio::test] + async fn resumable_download_discards_an_unreadable_or_mismatched_marker() -> Result<()> { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let url = format!("{}/consensus.tar.lz4", server.uri()); + Mock::given(method("GET")) + .and(path("/consensus.tar.lz4")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(b"fresh".to_vec()) + .append_header("Content-Length", "5"), + ) + .expect(2) + .mount(&server) + .await; + + for marker in [b"\xff".as_slice(), b"different identity".as_slice()] { + let dir = tempfile::tempdir()?; + let (_, marker_path) = seed_partial_download(dir.path(), &url, b"stale")?; + std::fs::write(marker_path, marker)?; + + let (path, _) = run_resumable_download(url.clone(), dir.path().to_path_buf()).await?; + + assert_eq!(std::fs::read(path)?, b"fresh"); + } + + let requests = server + .received_requests() + .await + .ok_or_else(|| eyre::eyre!("Request recording is disabled"))?; + assert_eq!(requests.len(), 2); + assert!(requests + .iter() + .all(|request| request.headers.get("range").is_none())); + Ok(()) + } + + #[tokio::test] + async fn resumable_download_stops_when_the_marker_cannot_be_written() -> Result<()> { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/consensus.tar.lz4")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let url = format!("{}/consensus.tar.lz4", server.uri()); + let dir = tempfile::tempdir()?; + let part_path = dir.path().join("consensus.tar.lz4.part"); + let marker_path = dir.path().join("consensus.tar.lz4.part.url"); + std::fs::write(&part_path, b"stale")?; + std::fs::create_dir(&marker_path)?; + + let error = run_resumable_download(url, dir.path().to_path_buf()) + .await + .expect_err("an unwritable marker must stop the download"); + + assert!(error.to_string().contains("partial download marker")); + assert!(!part_path.exists()); + Ok(()) + } + + #[tokio::test] + async fn resumable_download_removes_the_marker_after_promotion() -> Result<()> { + let server = wiremock::MockServer::start().await; + let url = format!("{}/consensus.tar.lz4", server.uri()); + let dir = tempfile::tempdir()?; + mount_full_download(&server, "/consensus.tar.lz4", b"complete").await; + + let (path, _) = run_resumable_download(url, dir.path().to_path_buf()).await?; + + assert_eq!(std::fs::read(path)?, b"complete"); + assert!(!dir.path().join("consensus.tar.lz4.part").exists()); + assert!(!dir.path().join("consensus.tar.lz4.part.url").exists()); + Ok(()) + } + + #[tokio::test] + async fn resumable_download_succeeds_when_the_marker_cannot_be_removed() -> Result<()> { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let url = format!("{}/consensus.tar.lz4", server.uri()); + let dir = tempfile::tempdir()?; + let marker_path = dir.path().join("consensus.tar.lz4.part.url"); + let responder_marker = marker_path.clone(); + Mock::given(method("GET")) + .and(path("/consensus.tar.lz4")) + .respond_with(move |_request: &wiremock::Request| { + std::fs::remove_file(&responder_marker) + .expect("the ownership marker must exist before the request"); + std::fs::create_dir(&responder_marker) + .expect("the marker path must become unremovable as a file"); + ResponseTemplate::new(200) + .set_body_bytes(b"complete".to_vec()) + .append_header("Content-Length", "8") + }) + .expect(1) + .mount(&server) + .await; + + let (downloaded_path, total) = + run_resumable_download(url, dir.path().to_path_buf()).await?; + + assert_eq!(std::fs::read(downloaded_path)?, b"complete"); + assert_eq!(total, 8); + assert!(marker_path.is_dir()); + Ok(()) + } + + // --------------------------------------------------------------------------- + // Chain + // --------------------------------------------------------------------------- + + #[test] + fn chain_display() { + assert_eq!(Chain::Testnet.to_string(), "testnet"); + assert_eq!(Chain::Devnet.to_string(), "devnet"); + assert_eq!(Chain::Mainnet.to_string(), "mainnet"); + } + + #[test] + fn arc_chain_arg_matches_the_clap_value_name() { + // `arc_chain_arg` restates the #[value(name = ...)] strings, so nothing + // but this keeps the two in step. Iterating value_variants() covers a + // new chain automatically. + use clap::ValueEnum; + + for chain in Chain::value_variants() { + let value = chain.to_possible_value().unwrap(); + assert_eq!(chain.arc_chain_arg(), value.get_name()); + // The two renderings are deliberately different: Display is the + // snapshot API's network name, arc_chain_arg is reth's. + assert_ne!(chain.arc_chain_arg(), chain.to_string()); + } + } + + #[test] + fn execution_snapshot_source_classifies_by_last_path_segment() { + // Left column: manifests. Right column: everything else, which restores + // natively. `--execution-url` is user-facing, so the shapes an operator + // can plausibly paste are all pinned here. + let manifests = [ + "https://x.example/snap/manifest.json", + // Pre-signed URLs carry a query string. + "https://x.example/snap/manifest.json?X-Amz-Signature=deadbeef", + "https://x.example/snap/manifest.json#fragment", + // Explicit inputs may also use paths without an authority. + "testnet/manifest.json", + "manifest.json", + ]; + let archives = [ + "https://x.example/snap/el.tar.lz4", + // A different file that merely ends in the same characters. + "https://x.example/snap/el-manifest.json", + "https://x.example/snap/notamanifest.json", + // A directory of that name is not the file. + "https://x.example/manifest.json/el.tar.lz4", + ]; + + for url in manifests { + assert_eq!( + ExecutionSnapshotSource::from_url(url.to_string()), + ExecutionSnapshotSource::Manifest(url.to_string()), + "expected a manifest: {url}" + ); + } + for url in archives { + assert_eq!( + ExecutionSnapshotSource::from_url(url.to_string()), + ExecutionSnapshotSource::Archive(url.to_string()), + "expected an archive: {url}" + ); + } + } + + #[tokio::test] + async fn resolve_snapshot_sources_prefers_manifest() { + let (uri, result) = resolve_listing(&[v2_snapshot_entry("testnet", "archive", 200)]).await; + + let (execution, consensus) = result.unwrap(); + assert_eq!( + execution, + ExecutionSnapshotSource::Manifest(format!( + "{uri}/download/testnet/storage-v2/archive/200/execution/manifest.json" + )) + ); + assert_eq!( + consensus, + format!("{uri}/download/testnet/storage-v2/archive/200/consensus.tar.lz4") + ); + assert!(!execution_url(&execution).contains('?')); + assert!(!consensus.contains('?')); + } + + #[tokio::test] + async fn resolve_snapshot_sources_selects_the_highest_complete_v2_block() { + let (uri, result) = resolve_listing(&[ + v2_snapshot_entry("testnet", "archive", 100), + v2_snapshot_entry("testnet", "archive", 300), + v2_snapshot_entry("testnet", "archive", 200), + ]) + .await; + + let (execution, consensus) = result.unwrap(); + assert_eq!( + execution, + ExecutionSnapshotSource::Manifest(format!( + "{uri}/download/testnet/storage-v2/archive/300/execution/manifest.json" + )) + ); + assert!(consensus.contains("/archive/300/")); + } + + #[tokio::test] + async fn both_resolvers_select_from_the_same_v2_entry() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let entries = [ + v2_snapshot_entry("testnet", "archive", 100), + v2_snapshot_entry("testnet", "archive", 200), + ]; + Mock::given(method("GET")) + .and(path("/snapshots")) + .respond_with(ResponseTemplate::new(200).set_body_string(snapshot_listing(&entries))) + .expect(2) + .mount(&server) + .await; + + let (_, paired_consensus) = resolve_snapshot_sources_from(Chain::Testnet, &server.uri()) + .await + .unwrap(); + let standalone = fetch_latest_consensus_url_from(Chain::Testnet, &server.uri()) + .await + .unwrap(); + + assert_eq!(paired_consensus, standalone); + assert!(standalone.contains("/archive/200/")); + } + + #[tokio::test] + async fn a_newer_incomplete_v2_entry_does_not_hide_an_older_complete_one() { + let mut incomplete = v2_snapshot_entry("testnet", "archive", 300); + incomplete.as_object_mut().unwrap().remove("consensus"); + let (uri, result) = + resolve_listing(&[v2_snapshot_entry("testnet", "archive", 200), incomplete]).await; + + let (execution, consensus) = result.unwrap(); + assert_eq!( + execution, + ExecutionSnapshotSource::Manifest(format!( + "{uri}/download/testnet/storage-v2/archive/200/execution/manifest.json" + )) + ); + assert!(consensus.contains("/archive/200/")); + } + + #[tokio::test] + async fn a_null_consensus_is_treated_as_incomplete() { + let mut incomplete = v2_snapshot_entry("testnet", "archive", 300); + incomplete["consensus"] = serde_json::Value::Null; + let (_uri, result) = + resolve_listing(&[v2_snapshot_entry("testnet", "archive", 200), incomplete]).await; + + let (_, consensus) = result.unwrap(); + assert!(consensus.contains("/archive/200/")); + } + + #[tokio::test] + async fn resolve_snapshot_sources_ignores_another_network() { + let (_uri, result) = resolve_listing(&[ + v2_snapshot_entry("devnet", "archive", 999), + v2_snapshot_entry("testnet", "archive", 200), + ]) + .await; + + let (_, consensus) = result.unwrap(); + assert!(consensus.contains("/testnet/")); + assert!(!consensus.contains("/devnet/")); + } + + #[tokio::test] + async fn legacy_snapshot_contents_do_not_affect_v2_selection() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let body = serde_json::json!({ + "snapshots": [ + "arbitrary", + { "blockNumber": 999999, "key": "testnet/v1.tar.lz4" } + ], + "v2Snapshots": [v2_snapshot_entry("testnet", "archive", 200)], + }); + Mock::given(method("GET")) + .and(path("/snapshots")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&server) + .await; + + let (_, consensus) = resolve_snapshot_sources_from(Chain::Testnet, &server.uri()) + .await + .unwrap(); + + assert!(consensus.contains("/archive/200/")); + } + + #[tokio::test] + async fn an_empty_v2_listing_reports_the_storage_v2_error() { + let (_uri, result) = resolve_listing(&[]).await; + + let error = result.unwrap_err().to_string(); + assert!(error.contains("testnet"), "unexpected: {error}"); + assert!( + error.contains("may not publish storage v2"), + "unexpected: {error}" + ); + } + + #[tokio::test] + async fn an_absent_v2_listing_reports_the_serde_field_error() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/snapshots")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "snapshots": [] })), + ) + .mount(&server) + .await; + + let error = resolve_snapshot_sources_from(Chain::Testnet, &server.uri()) + .await + .unwrap_err() + .to_string(); + + assert!( + error.contains("missing field `v2Snapshots`"), + "unexpected: {error}" + ); + } + + #[tokio::test] + async fn v2_retention_values_compete_on_block_number_alone() { + let (_uri, result) = resolve_listing(&[ + v2_snapshot_entry("testnet", "archive", 200), + v2_snapshot_entry("testnet", "pruned", 300), + ]) + .await; + + let (execution, consensus) = result.unwrap(); + assert!(execution_url(&execution).contains("/pruned/300/")); + assert!(consensus.contains("/pruned/300/")); + } + + /// Serves `entries` as a v2 listing and resolves sources from it. + async fn resolve_listing( + entries: &[serde_json::Value], + ) -> (String, Result<(ExecutionSnapshotSource, String)>) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/snapshots")) + .respond_with(ResponseTemplate::new(200).set_body_string(snapshot_listing(entries))) + .mount(&server) + .await; + + let uri = server.uri(); + let result = resolve_snapshot_sources_from(Chain::Testnet, &uri).await; + (uri, result) + } + + fn execution_url(source: &ExecutionSnapshotSource) -> &str { + match source { + ExecutionSnapshotSource::Manifest(url) | ExecutionSnapshotSource::Archive(url) => url, + } + } + + #[test] + fn chain_default_execution_path_ends_with_arc_execution() { + // BaseDirs resolves on any OS with a home dir; in CI HOME is always set. + if let Some(p) = Chain::default_execution_path() { + assert!(p.ends_with(".arc/execution")); + } + } + + #[test] + fn chain_default_consensus_path_ends_with_arc_consensus() { + if let Some(p) = Chain::default_consensus_path() { + assert!(p.ends_with(".arc/consensus")); + } + } + + // --------------------------------------------------------------------------- + // DownloadProgress helpers + // --------------------------------------------------------------------------- + + #[test] + fn format_size_bytes() { + assert_eq!(DownloadProgress::format_size(0), "0.00 B"); + assert_eq!(DownloadProgress::format_size(512), "512.00 B"); + } + + #[test] + fn format_size_kilobytes() { + assert_eq!(DownloadProgress::format_size(1024), "1.00 KB"); + assert_eq!(DownloadProgress::format_size(2048), "2.00 KB"); + } + + #[test] + fn format_size_megabytes() { + assert_eq!(DownloadProgress::format_size(1024 * 1024), "1.00 MB"); + } + + #[test] + fn format_size_gigabytes() { + assert_eq!(DownloadProgress::format_size(1024 * 1024 * 1024), "1.00 GB"); + } + + #[test] + fn format_duration_seconds() { + assert_eq!( + DownloadProgress::format_duration(Duration::from_secs(45)), + "45s" + ); + } + + #[test] fn format_duration_minutes() { assert_eq!( DownloadProgress::format_duration(Duration::from_secs(90)), @@ -751,37 +1799,271 @@ mod tests { } // --------------------------------------------------------------------------- - // download_and_extract via local HTTP server (wiremock) + // replace_consensus_store via local HTTP server (wiremock) // --------------------------------------------------------------------------- #[tokio::test] - async fn download_and_extract_fetches_and_extracts() -> Result<()> { + async fn replace_consensus_store_fetches_and_extracts() -> Result<()> { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let data = build_tar_lz4(&[("store.db", b"consensus-data")])?; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/snapshot.tar.lz4")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(data.clone()) + .append_header("Content-Length", data.len().to_string().as_str()), + ) + .mount(&server) + .await; + + let dir = tempfile::tempdir()?; + let dest = dir.path().join("dest"); + let tmp = dir.path().join("tmp"); + let url = format!("{}/snapshot.tar.lz4", server.uri()); + + tokio::task::spawn_blocking(move || replace_consensus_store(&url, &dest, &tmp)).await??; + + assert!(dir.path().join("dest/store.db").exists()); + assert!(dir.path().join("dest").join(SNAPSHOT_VERSION_FILE).exists()); + // tmp dir should be cleaned up + assert!(!dir.path().join("tmp").exists()); + Ok(()) + } + + // --------------------------------------------------------------------------- + // restore_consensus + // --------------------------------------------------------------------------- + + /// Serves one `.tar.lz4` archive containing `store.db` and returns its URL. + async fn serve_consensus_archive(server: &wiremock::MockServer) -> Result { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + + let data = build_tar_lz4(&[("store.db", b"fresh-consensus-data")])?; + Mock::given(method("GET")) + .and(path("/cl.tar.lz4")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(data.clone()) + .append_header("Content-Length", data.len().to_string().as_str()), + ) + .mount(server) + .await; + Ok(format!("{}/cl.tar.lz4", server.uri())) + } + + #[tokio::test] + async fn restore_consensus_skips_when_the_marker_matches() -> Result<()> { + let dir = tempfile::tempdir()?; + let consensus = dir.path().join("consensus"); + std::fs::create_dir_all(&consensus)?; + std::fs::write(consensus.join("store.db"), b"existing")?; + write_snapshot_version(&consensus, "http://unreachable.invalid/cl.tar.lz4")?; + + // No mock server: reaching the network would fail the test. + restore_consensus( + "http://unreachable.invalid/cl.tar.lz4", + &consensus, + &dir.path().join("tmp"), + false, + )?; + + assert_eq!(std::fs::read(consensus.join("store.db"))?, b"existing"); + Ok(()) + } + + #[tokio::test] + async fn restore_consensus_keeps_the_malachite_home_under_force() -> Result<()> { + // The consensus directory is the malachite home. --force replaces the + // store, but deleting the directory would take the validator's private + // key and the consensus WAL with it — see `restore_consensus`. + let server = wiremock::MockServer::start().await; + let url = serve_consensus_archive(&server).await?; + + let dir = tempfile::tempdir()?; + let consensus = dir.path().join("consensus"); + std::fs::create_dir_all(consensus.join("config"))?; + std::fs::create_dir_all(consensus.join("wal"))?; + std::fs::write(consensus.join("store.db"), b"stale")?; + std::fs::write( + consensus.join("config/priv_validator_key.json"), + b"validator-key", + )?; + std::fs::write(consensus.join("wal/consensus.wal"), b"wal-entries")?; + let tmp = dir.path().join("tmp"); + + let (consensus_arg, url_arg) = (consensus.clone(), url.clone()); + tokio::task::spawn_blocking(move || { + restore_consensus(&url_arg, &consensus_arg, &tmp, true) + }) + .await??; + + // The store is replaced... + assert_eq!( + std::fs::read(consensus.join("store.db"))?, + b"fresh-consensus-data" + ); + assert_eq!( + std::fs::read_to_string(consensus.join(SNAPSHOT_VERSION_FILE))?, + url + ); + // ...and the node's identity survives. + assert_eq!( + std::fs::read(consensus.join("config/priv_validator_key.json"))?, + b"validator-key" + ); + // So does the WAL. Malachite wipes it when its height does not match the + // one the restored node starts at, and replays it when it does — and that + // replay is what makes the node re-cast the vote it cast before. + assert_eq!( + std::fs::read(consensus.join("wal/consensus.wal"))?, + b"wal-entries" + ); + Ok(()) + } + + #[test] + fn url_identity_drops_a_regenerated_signature() { + // Keeping one would make every freshly signed URL for the same snapshot + // read as a new snapshot and re-fetch the whole layer. + let bare = "https://x.example/testnet/manifest.json"; + for query in [ + "X-Amz-Signature=deadbeef&X-Amz-Date=20260813T000000Z", + "x-amz-signature=deadbeef", + "X-Goog-Signature=deadbeef", + "AWSAccessKeyId=AKIA&Expires=1&Signature=deadbeef", + ] { + assert_eq!(url_identity(&format!("{bare}?{query}")), bare, "{query}"); + } + } + + #[test] + fn url_identity_keeps_a_parameter_that_addresses_the_snapshot() { + // The failure this prevents is silent: two chains sharing one identity + // means restoring either reports the other as up to date, and the node + // starts on a datadir for the wrong network. + let devnet = url_identity("https://x.example/manifest.json?network=arc-devnet"); + let testnet = url_identity("https://x.example/manifest.json?network=arc-testnet"); + assert_ne!(devnet, testnet); + assert!(devnet.contains("network=arc-devnet")); + + // And a signature alongside one is still dropped. + assert_eq!( + url_identity("https://x.example/manifest.json?network=arc-devnet&X-Amz-Signature=dead"), + devnet + ); + } + + #[test] + fn url_identity_ignores_parameter_order() { + // A resolver reordering its parameters must not count as a new snapshot. + assert_eq!( + url_identity("https://x.example/m.json?a=1&b=2"), + url_identity("https://x.example/m.json?b=2&a=1") + ); + } + + #[test] + fn url_identity_drops_the_fragment_and_normalizes_once() { + // A fragment never reaches the server, so it addresses nothing. + assert_eq!( + url_identity("https://x.example/cl.tar.lz4#part"), + "https://x.example/cl.tar.lz4" + ); + assert_eq!( + url_identity("https://x.example/m.json?network=devnet#part"), + "https://x.example/m.json?network=devnet" + ); + // Idempotent, so normalizing an already-composed marker is harmless. + let once = url_identity("https://x.example/m.json?a=1"); + assert_eq!(url_identity(&once), once); + } + + #[test] + fn manifest_classification_survives_a_query_string() { + // The identity now keeps semantic parameters, so classification has to + // read the path — otherwise the last segment is "manifest.json?network=x" + // and a manifest URL is restored as if it were a single archive. + assert!(matches!( + ExecutionSnapshotSource::from_url( + "https://x.example/manifest.json?network=arc-devnet".to_string() + ), + ExecutionSnapshotSource::Manifest(_) + )); + assert!(matches!( + ExecutionSnapshotSource::from_url( + "https://x.example/el.tar.lz4?network=arc-devnet".to_string() + ), + ExecutionSnapshotSource::Archive(_) + )); + } + + #[tokio::test] + async fn restore_consensus_ignores_a_changed_query_string() -> Result<()> { + // A pre-signed URL is re-signed on every resolution. The marker records + // the snapshot, not the signature, so the second run must skip rather + // than re-download an identical archive. use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let data = build_tar_lz4(&[("store.db", b"consensus-data")])?; - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/snapshot.tar.lz4")) + let data = build_tar_lz4(&[("store.db", b"fresh-consensus-data")])?; + let mock = Mock::given(method("GET")) + .and(path("/cl.tar.lz4")) .respond_with( ResponseTemplate::new(200) .set_body_bytes(data.clone()) .append_header("Content-Length", data.len().to_string().as_str()), ) - .mount(&server) + .expect(1) + .mount_as_scoped(&server) .await; let dir = tempfile::tempdir()?; - let dest = dir.path().join("dest"); + let consensus = dir.path().join("consensus"); let tmp = dir.path().join("tmp"); - let url = format!("{}/snapshot.tar.lz4", server.uri()); + let base = format!("{}/cl.tar.lz4", server.uri()); + + for signature in ["aaa", "bbb"] { + let url = format!("{base}?X-Amz-Signature={signature}"); + let (consensus_arg, tmp_arg) = (consensus.clone(), tmp.clone()); + tokio::task::spawn_blocking(move || { + restore_consensus(&url, &consensus_arg, &tmp_arg, false) + }) + .await??; + } + + // No signature persisted, and only one download happened. + assert_eq!( + std::fs::read_to_string(consensus.join(SNAPSHOT_VERSION_FILE))?, + base + ); + drop(mock); + Ok(()) + } - tokio::task::spawn_blocking(move || download_and_extract(&url, &dest, &tmp)).await??; + #[tokio::test] + async fn restore_consensus_cleans_up_the_staging_directory() -> Result<()> { + let server = wiremock::MockServer::start().await; + let url = serve_consensus_archive(&server).await?; - assert!(dir.path().join("dest/store.db").exists()); - // tmp dir should be cleaned up - assert!(!dir.path().join("tmp").exists()); + let dir = tempfile::tempdir()?; + let consensus = dir.path().join("consensus"); + let tmp = dir.path().join(".snapshot-tmp"); + + let (consensus_arg, tmp_arg) = (consensus.clone(), tmp.clone()); + tokio::task::spawn_blocking(move || { + restore_consensus(&url, &consensus_arg, &tmp_arg, false) + }) + .await??; + + assert!(consensus.join("store.db").exists()); + // Both the staging subdirectory and its parent are removed. + assert!(!tmp.exists()); Ok(()) } @@ -842,6 +2124,73 @@ mod tests { Ok(()) } + #[tokio::test] + async fn download_and_extract_both_replaces_a_manifest_shaped_datadir() -> Result<()> { + // The fixture archive names only db/, standing in for the general case: + // extraction leaves whatever the archive does not name, so a datadir from + // an earlier restore has to be removed rather than written over. + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let el_data = build_tar_lz4(&[("db/mdbx.dat", b"archive-el")])?; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/el.tar.lz4")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(el_data.clone()) + .append_header("Content-Length", el_data.len().to_string().as_str()), + ) + .mount(&server) + .await; + + let dir = tempfile::tempdir()?; + let el_dest = dir.path().join("el"); + let cl_dest = dir.path().join("cl"); + let tmp = dir.path().join("tmp"); + let el_url = format!("{}/el.tar.lz4", server.uri()); + let cl_url = "http://unreachable.invalid/cl.tar.lz4".to_string(); + + // A datadir a manifest restore left: extra component directories, and a + // marker naming the manifest so the archive URL is a mismatch. + std::fs::create_dir_all(el_dest.join("db"))?; + std::fs::create_dir_all(el_dest.join("static_files"))?; + std::fs::write(el_dest.join("db/mdbx.dat"), b"manifest-el")?; + std::fs::write(el_dest.join("static_files/headers.jar"), b"stale")?; + std::fs::write(el_dest.join("reth.toml"), b"stale")?; + write_snapshot_version(&el_dest, "http://x/manifest.json el-profile=full")?; + // The consensus layer is already current, so it stays off the network. + std::fs::create_dir_all(&cl_dest)?; + std::fs::write(cl_dest.join("store.db"), b"cl")?; + write_snapshot_version(&cl_dest, &cl_url)?; + + let (el_dest_arg, cl_dest_arg) = (el_dest.clone(), cl_dest.clone()); + let el_url_arg = el_url.clone(); + tokio::task::spawn_blocking(move || { + download_and_extract_both( + &el_url_arg, + &cl_url, + &el_dest_arg, + &cl_dest_arg, + &tmp, + false, + ) + }) + .await??; + + assert_eq!(std::fs::read(el_dest.join("db/mdbx.dat"))?, b"archive-el"); + assert!( + !el_dest.join("static_files/headers.jar").exists(), + "a manifest restore's components must not survive an archive restore" + ); + assert!(!el_dest.join("reth.toml").exists()); + assert_eq!( + std::fs::read_to_string(el_dest.join(SNAPSHOT_VERSION_FILE))?, + el_url + ); + Ok(()) + } + #[tokio::test] async fn download_and_extract_both_skips_existing() -> Result<()> { use wiremock::matchers::{method, path}; @@ -976,6 +2325,57 @@ mod tests { Ok(()) } + #[tokio::test] + async fn download_and_extract_both_force_keeps_the_validator_key() -> Result<()> { + // Same rule as restore_consensus: the forced pair restore replaces both + // stores but must not delete the malachite home around one of them. + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let el_data = build_tar_lz4(&[("db/mdbx.dat", b"new-el")])?; + let cl_data = build_tar_lz4(&[("store.db", b"new-cl")])?; + + let server = MockServer::start().await; + for (route, body) in [("/el.tar.lz4", &el_data), ("/cl.tar.lz4", &cl_data)] { + Mock::given(method("GET")) + .and(path(route)) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(body.clone()) + .append_header("Content-Length", body.len().to_string().as_str()), + ) + .mount(&server) + .await; + } + + let dir = tempfile::tempdir()?; + let el_dest = dir.path().join("el"); + let cl_dest = dir.path().join("cl"); + let tmp = dir.path().join("tmp"); + let el_url = format!("{}/el.tar.lz4", server.uri()); + let cl_url = format!("{}/cl.tar.lz4", server.uri()); + + std::fs::create_dir_all(cl_dest.join("config"))?; + std::fs::write( + cl_dest.join("config/priv_validator_key.json"), + b"validator-key", + )?; + + let (el_dest_arg, cl_dest_arg) = (el_dest.clone(), cl_dest.clone()); + tokio::task::spawn_blocking(move || { + download_and_extract_both(&el_url, &cl_url, &el_dest_arg, &cl_dest_arg, &tmp, true) + }) + .await??; + + assert_eq!(std::fs::read(el_dest.join("db/mdbx.dat"))?, b"new-el"); + assert_eq!(std::fs::read(cl_dest.join("store.db"))?, b"new-cl"); + assert_eq!( + std::fs::read(cl_dest.join("config/priv_validator_key.json"))?, + b"validator-key" + ); + Ok(()) + } + #[tokio::test] async fn download_and_extract_both_redownloads_when_url_differs() -> Result<()> { use wiremock::matchers::{method, path}; @@ -1052,13 +2452,51 @@ mod tests { } #[tokio::test] - async fn download_and_extract_both_skips_when_marker_missing() -> Result<()> { + async fn download_and_extract_both_refuses_data_it_did_not_restore() -> Result<()> { + // A node that synced from genesis, or a validator signing since + // `arc-node-consensus init`, has data and no marker. So does a restore + // that died before writing one, and the two are indistinguishable. + // You need to use the --force flag to replace them. + let dir = tempfile::tempdir()?; + let el_dest = dir.path().join("el"); + let cl_dest = dir.path().join("cl"); + + std::fs::create_dir_all(el_dest.join("db"))?; + std::fs::write(el_dest.join("db/mdbx.dat"), b"self-synced")?; + std::fs::create_dir_all(&cl_dest)?; + std::fs::write(cl_dest.join("store.db"), b"own-votes")?; + + let (el_dest_arg, cl_dest_arg) = (el_dest.clone(), cl_dest.clone()); + let tmp = dir.path().join("tmp"); + let err = tokio::task::spawn_blocking(move || { + download_and_extract_both( + "http://unreachable.invalid/el.tar.lz4", + "http://unreachable.invalid/cl.tar.lz4", + &el_dest_arg, + &cl_dest_arg, + &tmp, + false, + ) + }) + .await? + .expect_err("data with no marker must stop the run"); + + assert!(err.to_string().contains("--force"), "unexpected: {err}"); + assert_eq!(std::fs::read(el_dest.join("db/mdbx.dat"))?, b"self-synced"); + assert_eq!(std::fs::read(cl_dest.join("store.db"))?, b"own-votes"); + Ok(()) + } + + #[tokio::test] + async fn download_and_extract_both_refuses_before_replacing_either_layer() -> Result<()> { + // The execution marker names an older snapshot, so that layer is due to be + // replaced, and its archive is served so the replacement would succeed. + // The consensus layer is unmarked and refuses. That refusal has to be + // decided first, or a rejected run still costs the operator the datadir. use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let el_data = build_tar_lz4(&[("db/mdbx.dat", b"el-data")])?; - let cl_data = build_tar_lz4(&[("store.db", b"cl-data")])?; - + let el_data = build_tar_lz4(&[("db/mdbx.dat", b"new-el")])?; let server = MockServer::start().await; let el_mock = Mock::given(method("GET")) .and(path("/el.tar.lz4")) @@ -1070,160 +2508,120 @@ mod tests { .expect(0) .mount_as_scoped(&server) .await; - let cl_mock = Mock::given(method("GET")) - .and(path("/cl.tar.lz4")) - .respond_with( - ResponseTemplate::new(200) - .set_body_bytes(cl_data.clone()) - .append_header("Content-Length", cl_data.len().to_string().as_str()), - ) - .expect(0) - .mount_as_scoped(&server) - .await; let dir = tempfile::tempdir()?; let el_dest = dir.path().join("el"); let cl_dest = dir.path().join("cl"); - let tmp = dir.path().join("tmp"); let el_url = format!("{}/el.tar.lz4", server.uri()); let cl_url = format!("{}/cl.tar.lz4", server.uri()); - // Pre-populate data WITHOUT version markers (simulates old tool or manual placement) std::fs::create_dir_all(el_dest.join("db"))?; - std::fs::write(el_dest.join("db/mdbx.dat"), b"existing-el")?; + std::fs::write(el_dest.join("db/mdbx.dat"), b"old-el")?; + write_snapshot_version(&el_dest, "http://x.example/older.tar.lz4")?; std::fs::create_dir_all(&cl_dest)?; - std::fs::write(cl_dest.join("store.db"), b"existing-cl")?; + std::fs::write(cl_dest.join("store.db"), b"own-votes")?; - tokio::task::spawn_blocking(move || { - download_and_extract_both(&el_url, &cl_url, &el_dest, &cl_dest, &tmp, false) + let (el_dest_arg, cl_dest_arg) = (el_dest.clone(), cl_dest.clone()); + let tmp = dir.path().join("tmp"); + let err = tokio::task::spawn_blocking(move || { + download_and_extract_both(&el_url, &cl_url, &el_dest_arg, &cl_dest_arg, &tmp, false) }) - .await??; + .await? + .expect_err("the consensus layer must refuse"); - // Data should be untouched - assert_eq!( - std::fs::read(dir.path().join("el/db/mdbx.dat"))?, - b"existing-el" - ); assert_eq!( - std::fs::read(dir.path().join("cl/store.db"))?, - b"existing-cl" + std::fs::read(el_dest.join("db/mdbx.dat"))?, + b"old-el", + "the execution layer was replaced before the consensus refusal: {err}" ); - // No marker file should have been written (no download happened) - assert!(!dir.path().join("el/.snapshot-url").exists()); - assert!(!dir.path().join("cl/.snapshot-url").exists()); - - // Verify mocks received 0 requests + // Not even fetched: the refusal precedes the download. drop(el_mock); - drop(cl_mock); Ok(()) } - // --------------------------------------------------------------------------- - // fetch_latest_snapshot_urls - // --------------------------------------------------------------------------- - - fn snapshot_listing(entries: &[serde_json::Value]) -> String { - serde_json::to_string(&serde_json::json!({ "snapshots": entries })).unwrap() - } - - fn snapshot_entry( - network: &str, - layer: &str, - key: &str, - block_number: u64, - ) -> serde_json::Value { - serde_json::json!({ - "key": key, - "network": network, - "retention": "pruned", - "layer": layer, - "blockNumber": block_number, - }) - } - #[tokio::test] - async fn fetch_latest_snapshot_urls_returns_correct_urls() -> Result<()> { - use wiremock::matchers::{method, path, query_param}; + async fn restore_consensus_withdraws_the_marker_before_extracting() -> Result<()> { + // The consensus directory is never removed, so its marker outlives a + // failed restore unless the restore withdraws it. Extraction truncates + // `store.db` as it writes, and here the snapshot being restored is the one + // the marker already names — a --force retry. Keep the marker and the next + // run reads a truncated store as up to date. + use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; + let partial = build_tar_lz4_failing_after("store.db", b"half-written")?; let server = MockServer::start().await; - let body = snapshot_listing(&[ - snapshot_entry( - "testnet", - "execution", - "testnet/el-34885446.tar.lz4", - 34885446, - ), - snapshot_entry( - "testnet", - "consensus", - "testnet/cl-34885446.tar.lz4", - 34885446, - ), - ]); Mock::given(method("GET")) - .and(path("/snapshots")) - .and(query_param("network", "testnet")) - .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .and(path("/cl.tar.lz4")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(partial.clone()) + .append_header("Content-Length", partial.len().to_string().as_str()), + ) .mount(&server) .await; - let (el_url, cl_url) = - fetch_latest_snapshot_urls_from(Chain::Testnet, &server.uri()).await?; + let dir = tempfile::tempdir()?; + let consensus = dir.path().join("consensus"); + std::fs::create_dir_all(&consensus)?; + std::fs::write(consensus.join("store.db"), b"stale")?; + let url = format!("{}/cl.tar.lz4", server.uri()); + write_snapshot_version(&consensus, &url)?; - assert_eq!( - el_url, - format!("{}/download/testnet/el-34885446.tar.lz4", server.uri()) - ); - assert_eq!( - cl_url, - format!("{}/download/testnet/cl-34885446.tar.lz4", server.uri()) - ); + let (consensus_arg, url_arg) = (consensus.clone(), url.clone()); + let tmp = dir.path().join("tmp"); + let err = tokio::task::spawn_blocking(move || { + restore_consensus(&url_arg, &consensus_arg, &tmp, true) + }) + .await? + .expect_err("a partial archive must not report success"); + + assert!(!consensus.join(SNAPSHOT_VERSION_FILE).exists(), "{err}"); + let rerun = should_download("Consensus layer", &consensus, &url, true, false) + .expect_err("a truncated store must not read as up to date"); + assert!(rerun.to_string().contains("--force"), "unexpected: {rerun}"); Ok(()) } - #[tokio::test] - async fn fetch_latest_snapshot_urls_picks_highest_common_block() -> Result<()> { - // EL has snapshots at 100 and 200; CL has 200 and 300. - // Must pick block 200 (the highest with both present), not 300 or 100. - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let body = snapshot_listing(&[ - snapshot_entry("testnet", "execution", "testnet/el-100.tar.lz4", 100), - snapshot_entry("testnet", "execution", "testnet/el-200.tar.lz4", 200), - snapshot_entry("testnet", "consensus", "testnet/cl-200.tar.lz4", 200), - snapshot_entry("testnet", "consensus", "testnet/cl-300.tar.lz4", 300), - ]); - Mock::given(method("GET")) - .and(path("/snapshots")) - .respond_with(ResponseTemplate::new(200).set_body_string(body)) - .mount(&server) - .await; + // --------------------------------------------------------------------------- + // Snapshot listing helpers + consensus URL resolver + // --------------------------------------------------------------------------- - let (el_url, cl_url) = - fetch_latest_snapshot_urls_from(Chain::Testnet, &server.uri()).await?; + fn snapshot_listing(entries: &[serde_json::Value]) -> String { + serde_json::to_string(&serde_json::json!({ + "snapshots": [{ "ignored": true }], + "v2Snapshots": entries, + })) + .unwrap() + } - assert!(el_url.contains("el-200"), "expected el-200, got {el_url}"); - assert!(cl_url.contains("cl-200"), "expected cl-200, got {cl_url}"); - Ok(()) + fn v2_snapshot_entry(network: &str, retention: &str, block_number: u64) -> serde_json::Value { + let prefix = format!("{network}/storage-v2/{retention}/{block_number}/"); + serde_json::json!({ + "network": network, + "retention": retention, + "blockNumber": block_number, + "timestamp": "2026-08-27T12:20:00Z", + "prefix": prefix, + "execution": { + "manifestKey": format!("{prefix}execution/manifest.json"), + "components": [{ "name": "state", "size": 55864490224_u64 }], + }, + "consensus": { + "key": format!("{prefix}consensus.tar.lz4"), + }, + }) } #[tokio::test] - async fn fetch_latest_snapshot_urls_filters_by_network() -> Result<()> { - // API returns mixed-network entries; only the requested network should be used. + async fn fetch_latest_consensus_url_returns_latest_complete_v2_entry() -> Result<()> { use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; let server = MockServer::start().await; let body = snapshot_listing(&[ - // devnet entries — should be ignored when querying testnet - snapshot_entry("devnet", "execution", "devnet/el-99999.tar.lz4", 99999), - snapshot_entry("devnet", "consensus", "devnet/cl-99999.tar.lz4", 99999), - // testnet entries — should be selected - snapshot_entry("testnet", "execution", "testnet/el-100.tar.lz4", 100), - snapshot_entry("testnet", "consensus", "testnet/cl-100.tar.lz4", 100), + v2_snapshot_entry("testnet", "archive", 100), + v2_snapshot_entry("testnet", "archive", 200), ]); Mock::given(method("GET")) .and(path("/snapshots")) @@ -1231,30 +2629,30 @@ mod tests { .mount(&server) .await; - let (el_url, cl_url) = - fetch_latest_snapshot_urls_from(Chain::Testnet, &server.uri()).await?; + let url = fetch_latest_consensus_url_from(Chain::Testnet, &server.uri()).await?; - assert!( - el_url.contains("testnet/el-100"), - "devnet entry must not be selected; got {el_url}" - ); - assert!( - cl_url.contains("testnet/cl-100"), - "devnet entry must not be selected; got {cl_url}" + assert_eq!( + url, + format!( + "{}/download/testnet/storage-v2/archive/200/consensus.tar.lz4", + server.uri() + ) ); Ok(()) } #[tokio::test] - async fn fetch_latest_snapshot_urls_errors_when_no_common_block() -> Result<()> { - // EL only at 100, CL only at 200 — no shared block height. + async fn fetch_latest_consensus_url_filters_by_network() -> Result<()> { + // The listing API returns entries for every network; only the requested + // network's consensus snapshot may be selected, even when another + // network has a higher block. use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; let server = MockServer::start().await; let body = snapshot_listing(&[ - snapshot_entry("testnet", "execution", "testnet/el.tar.lz4", 100), - snapshot_entry("testnet", "consensus", "testnet/cl.tar.lz4", 200), + v2_snapshot_entry("devnet", "archive", 99999), + v2_snapshot_entry("testnet", "archive", 100), ]); Mock::given(method("GET")) .and(path("/snapshots")) @@ -1262,77 +2660,48 @@ mod tests { .mount(&server) .await; - let err = fetch_latest_snapshot_urls_from(Chain::Testnet, &server.uri()) - .await - .unwrap_err(); - - assert!( - err.to_string().contains("no matching EL+CL"), - "unexpected error: {err}" - ); - Ok(()) - } - - #[tokio::test] - async fn fetch_latest_snapshot_urls_errors_when_no_execution_entry() -> Result<()> { - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let body = snapshot_listing(&[snapshot_entry( - "testnet", - "consensus", - "testnet/cl.tar.lz4", - 100, - )]); - Mock::given(method("GET")) - .and(path("/snapshots")) - .respond_with(ResponseTemplate::new(200).set_body_string(body)) - .mount(&server) - .await; - - let err = fetch_latest_snapshot_urls_from(Chain::Testnet, &server.uri()) - .await - .unwrap_err(); + let url = fetch_latest_consensus_url_from(Chain::Testnet, &server.uri()).await?; assert!( - err.to_string().contains("no pruned execution snapshot"), - "unexpected error: {err}" + url.contains("testnet/storage-v2/archive/100"), + "devnet entry must not be selected; got {url}" ); Ok(()) } #[tokio::test] - async fn fetch_latest_snapshot_urls_errors_when_no_consensus_entry() -> Result<()> { - use wiremock::matchers::{method, path}; + async fn snapshot_listing_queries_the_bare_network_name() { + // The API's ?network= takes `devnet`, not `arc-devnet`. Each mock answers + // only the bare name, so rendering the reth-facing value here would 404 + // and fail the call — which is how this would otherwise break: silently. + use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let server = MockServer::start().await; - let body = snapshot_listing(&[snapshot_entry( - "testnet", - "execution", - "testnet/el.tar.lz4", - 100, - )]); - Mock::given(method("GET")) - .and(path("/snapshots")) - .respond_with(ResponseTemplate::new(200).set_body_string(body)) - .mount(&server) - .await; - - let err = fetch_latest_snapshot_urls_from(Chain::Testnet, &server.uri()) - .await - .unwrap_err(); - - assert!( - err.to_string().contains("no pruned consensus snapshot"), - "unexpected error: {err}" - ); - Ok(()) + for (chain, network) in [ + (Chain::Testnet, "testnet"), + (Chain::Devnet, "devnet"), + (Chain::Mainnet, "mainnet"), + ] { + let server = MockServer::start().await; + let body = snapshot_listing(&[v2_snapshot_entry(network, "archive", 1)]); + Mock::given(method("GET")) + .and(path("/snapshots")) + .and(query_param("network", network)) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + + assert!( + fetch_latest_consensus_url_from(chain, &server.uri()) + .await + .is_ok(), + "{chain} must query network={network}" + ); + } } #[tokio::test] - async fn fetch_latest_snapshot_urls_propagates_http_error() -> Result<()> { + async fn fetch_latest_consensus_url_propagates_http_error() -> Result<()> { use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -1343,42 +2712,17 @@ mod tests { .mount(&server) .await; - let err = fetch_latest_snapshot_urls_from(Chain::Testnet, &server.uri()) + let err = fetch_latest_consensus_url_from(Chain::Testnet, &server.uri()) .await .unwrap_err(); - // reqwest's error_for_status surfaces the HTTP status code + // reqwest's error_for_status surfaces the HTTP status code. assert!(err.to_string().contains("500"), "unexpected error: {err}"); Ok(()) } #[tokio::test] - async fn fetch_latest_snapshot_urls_uses_devnet_network_param() -> Result<()> { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let body = snapshot_listing(&[ - snapshot_entry("devnet", "execution", "devnet/el.tar.lz4", 42), - snapshot_entry("devnet", "consensus", "devnet/cl.tar.lz4", 42), - ]); - // Only matches if the network param is exactly "devnet" (no "arc-" prefix) - Mock::given(method("GET")) - .and(path("/snapshots")) - .and(query_param("network", "devnet")) - .respond_with(ResponseTemplate::new(200).set_body_string(body)) - .mount(&server) - .await; - - // Returns an error if the query param doesn't match (mock returns 404 by default) - fetch_latest_snapshot_urls_from(Chain::Devnet, &server.uri()) - .await - .expect("devnet query param must be 'devnet'"); - Ok(()) - } - - #[tokio::test] - async fn download_and_extract_cleans_tmp_on_extraction_failure() -> Result<()> { + async fn replace_consensus_store_cleans_tmp_on_extraction_failure() -> Result<()> { use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -1415,7 +2759,7 @@ mod tests { let url = format!("{}/bad.tar.lz4", server.uri()); let result = - tokio::task::spawn_blocking(move || download_and_extract(&url, &dest, &tmp)).await?; + tokio::task::spawn_blocking(move || replace_consensus_store(&url, &dest, &tmp)).await?; assert!(result.is_err()); // tmp should be cleaned up even on failure diff --git a/crates/snapshots/src/lib.rs b/crates/snapshots/src/lib.rs index db2e9b49..511db6bf 100644 --- a/crates/snapshots/src/lib.rs +++ b/crates/snapshots/src/lib.rs @@ -17,3 +17,4 @@ //! arc-snapshots library — download and extract Arc node snapshots. pub mod download; +pub mod manifest; diff --git a/crates/snapshots/src/main.rs b/crates/snapshots/src/main.rs index 571cc751..0fbe1925 100644 --- a/crates/snapshots/src/main.rs +++ b/crates/snapshots/src/main.rs @@ -16,13 +16,23 @@ //! arc-snapshots — download and extract Arc node snapshots. -use std::path::PathBuf; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; -use arc_snapshots::download::{self, Chain}; use clap::{Parser, Subcommand}; use eyre::Result; +use tokio::task; use tracing::info; +use arc_snapshots::download::{self, Chain, ExecutionSnapshotSource}; +use arc_snapshots::manifest::{self, ElProfile}; + +/// Environment variable that overrides the execution binary. +const EXECUTION_BINARY_ENV: &str = "ARC_EXECUTION_BINARY"; + +/// Default execution binary, resolved on PATH unless overridden. +const DEFAULT_EXECUTION_BINARY: &str = "arc-node-execution"; + #[derive(Debug, Parser)] #[command( name = "arc-snapshots", @@ -51,19 +61,25 @@ enum Commands { struct DownloadArgs { /// URL of the execution layer snapshot archive. /// - /// If omitted, the latest snapshot for --chain is fetched automatically. + /// Give this together with --consensus-url, or omit both to fetch the latest + /// matched pair for --chain. #[arg(long)] execution_url: Option, /// URL of the consensus layer snapshot archive. /// - /// If omitted, the latest snapshot for --chain is fetched automatically. + /// Give this together with --execution-url, or omit both to fetch the latest + /// matched pair for --chain. #[arg(long)] consensus_url: Option, /// Network to download a snapshot for. - #[arg(long, default_value = "arc-testnet")] - chain: Chain, + /// + /// Required when a snapshot URL is not given, since it selects the latest + /// snapshot from the API. Also required when the execution snapshot is a + /// manifest url: arc-node-execution uses it to select the chainspec. + #[arg(long)] + chain: Option, /// Directory to extract execution layer data into. /// @@ -77,9 +93,28 @@ struct DownloadArgs { #[arg(long)] consensus_path: Option, - /// Force re-download even if snapshot data already exists in the target directories. + /// Restore both layers whatever they already hold. + /// + /// Without this, a layer holding the requested snapshot is left alone, and a + /// layer holding data that no restore recorded stops the run. + /// + /// This changes only *whether* a layer is restored, not how. Within one layer, + /// a restore does not leave files from an older snapshot beside the new one. + /// + /// The layers are restored in sequence, so an interrupted run can leave them + /// at different stages. Markers make that detectable rather than self-healing: + /// a layer holding data with no marker is not overwritten by a restore + /// unless the operator passes `--force`. #[arg(long = "force")] force_redownload: bool, + + /// Execution component preset for a manifest download: minimal, full, or + /// archive. + /// + /// Defaults to minimal. The preset applies only when the execution snapshot + /// is a manifest. + #[arg(long, value_enum, default_value_t = ElProfile::Minimal)] + el_profile: ElProfile, } #[tokio::main] @@ -97,66 +132,799 @@ async fn main() -> Result<()> { } } -pub(crate) async fn run_download(args: DownloadArgs) -> Result<()> { - let chain = args.chain; - - let (execution_url, consensus_url) = match (args.execution_url, args.consensus_url) { - (Some(el), Some(cl)) => (el, cl), - (Some(_), None) | (None, Some(_)) => { - eyre::bail!("provide both --execution-url and --consensus-url, or neither") - } - (None, None) => { - info!(chain = %chain, "Fetching latest snapshot URLs"); - download::fetch_latest_snapshot_urls(chain).await? - } - }; +/// The directories a restore writes to: the execution and consensus targets +/// and the staging directory downloads land in first. +struct SnapshotDirs { + execution: PathBuf, + consensus: PathBuf, + tmp: PathBuf, +} - let execution_dir = args +pub(crate) async fn run_download(args: DownloadArgs) -> Result<()> { + let execution = args .execution_path - .or_else(|| chain.default_execution_path()) + .or_else(Chain::default_execution_path) .ok_or_else(|| { eyre::eyre!("Could not determine default execution path; use --execution-path") })?; - - let consensus_dir = args + let consensus = args .consensus_path .or_else(Chain::default_consensus_path) .ok_or_else(|| { eyre::eyre!("Could not determine default consensus path; use --consensus-path") })?; + let tmp = snapshot_tmp_dir(&execution, &consensus)?; + let dirs = SnapshotDirs { + execution, + consensus, + tmp, + }; + + let (source, consensus_url) = + resolve_sources(args.execution_url, args.consensus_url, args.chain).await?; + + match source { + ExecutionSnapshotSource::Archive(execution_url) => { + run_download_archive_snapshot( + execution_url, + consensus_url, + dirs, + args.force_redownload, + ) + .await?; + } + ExecutionSnapshotSource::Manifest(manifest_url) => { + let chain = args + .chain + .ok_or_else(|| eyre::eyre!("--chain is required for a manifest download"))?; + let restore = ManifestRestore { + chain, + profile: args.el_profile, + manifest_url, + consensus_url, + dirs, + force_redownload: args.force_redownload, + binary: execution_binary()?, + }; + run_download_manifest_snapshot(restore, manifest::CommandDownloader).await?; + } + } - let tmp_dir = execution_dir.join(".snapshot-tmp"); + info!("Snapshot operation complete"); + Ok(()) +} +/// Restores both layers from single lz4 archives with the native path. +async fn run_download_archive_snapshot( + execution_url: String, + consensus_url: String, + dirs: SnapshotDirs, + force_redownload: bool, +) -> Result<()> { info!( execution_url = %execution_url, consensus_url = %consensus_url, - execution_dir = %execution_dir.display(), - consensus_dir = %consensus_dir.display(), + execution_dir = %dirs.execution.display(), + consensus_dir = %dirs.consensus.display(), "Starting snapshot download" ); - download::stream_and_extract_both( execution_url, consensus_url, - execution_dir, - consensus_dir, - tmp_dir, - args.force_redownload, + dirs.execution, + dirs.consensus, + dirs.tmp, + force_redownload, + ) + .await +} + +/// A manifest restore: which snapshot, where it goes, and how much of the +/// execution layer to fetch. +/// +/// A struct rather than a parameter list because `manifest_url` and +/// `consensus_url` are both `String`: swapping them at a call site would +/// compile and hand each URL to the wrong layer. +struct ManifestRestore { + /// Chain passed to `arc-node-execution download --chain`. + chain: Chain, + /// Execution component preset to fetch. + profile: ElProfile, + /// URL of the reth `manifest.json`. + manifest_url: String, + /// URL of the consensus `.tar.lz4` archive. + consensus_url: String, + /// Restore targets and the staging directory. + dirs: SnapshotDirs, + /// Whether to discard existing data instead of skipping up-to-date layers. + force_redownload: bool, + /// Execution binary the manifest is handed to. + binary: OsString, +} + +/// Restores the execution layer from a reth manifest with +/// `arc-node-execution download` and the consensus layer with the native path. +/// +/// `downloader` is a parameter so tests can record the invocation instead of +/// launching a process. +async fn run_download_manifest_snapshot(restore: ManifestRestore, downloader: D) -> Result<()> +where + D: manifest::ExecutionDownloader + Send + 'static, +{ + let ManifestRestore { + chain, + profile, + manifest_url, + consensus_url, + dirs, + force_redownload, + binary, + } = restore; + + let marker = manifest::manifest_marker(&manifest_url, profile); + // Before the consensus restore, so a datadir this tool must not touch stops + // the run rather than aborting it with one layer already replaced. + let restore_execution = download::should_download( + "Execution layer", + &dirs.execution, + &marker, + download::execution_snapshot_exists(&dirs.execution), + force_redownload, + )?; + if restore_execution { + // reth writes into the datadir in place, so the directory is deleted + // before the download. + // Checking that the binary is there before deleting the existing data. + downloader.ensure_available(&binary)?; + } + + info!( + chain = %chain, + manifest_url = %manifest_url, + execution_dir = %dirs.execution.display(), + consensus_dir = %dirs.consensus.display(), + "Starting snapshot download" + ); + + download::stream_restore_consensus( + consensus_url, + dirs.consensus.clone(), + dirs.tmp.clone(), + force_redownload, ) .await?; - info!("Snapshot operation complete"); + if restore_execution { + // reth writes into the datadir in place and writes only the files its + // manifest lists, so whatever is already there survives and mixes with + // the new data. Reaching this branch means the datadir is being + // replaced — because --force says so, or because the marker names a + // different snapshot — so it has to go first either way. There is no + // staging alternative: the download lands directly in the datadir. + manifest::clear_datadir(&dirs.execution)?; + + let el_dir = dirs.execution.clone(); + let el_url = manifest_url.clone(); + task::spawn_blocking(move || { + let opts = manifest::ExecutionDownload { + chain, + profile, + datadir: &el_dir, + manifest_url: el_url.as_str(), + }; + manifest::run_execution_download(&downloader, &binary, &opts) + }) + .await??; + + // Reached only when the child exits zero, so an interrupted restore + // leaves a datadir with no marker — which the next run refuses to touch + // without --force rather than starting a node on part of a snapshot. + download::write_snapshot_version(&dirs.execution, &marker)?; + } + Ok(()) } +/// Determines the execution snapshot source and consensus URL. +/// +/// Explicit URLs are given together so both layers come from the same block. A +/// consensus snapshot from a different block leaves the node unable to hand off +/// between the layers. When both URLs are omitted, the latest matched pair for +/// `chain` is resolved from the API. +async fn resolve_sources( + execution_url: Option, + consensus_url: Option, + chain: Option, +) -> Result<(ExecutionSnapshotSource, String)> { + match (execution_url, consensus_url) { + (Some(el), Some(cl)) => Ok((ExecutionSnapshotSource::from_url(el), cl)), + (Some(_), None) => eyre::bail!( + "--execution-url requires --consensus-url; omit both to resolve a matched pair" + ), + (None, None) => { + let chain = chain.ok_or_else(|| { + eyre::eyre!( + "--chain is required when --execution-url and --consensus-url are not provided" + ) + })?; + info!(chain = %chain, "Fetching latest snapshot URLs"); + download::resolve_snapshot_sources(chain).await + } + (None, Some(_)) => eyre::bail!( + "--consensus-url requires --execution-url; omit both to resolve a matched pair" + ), + } +} + +/// The execution binary a manifest download is handed to. +/// +/// Defaults to `arc-node-execution` on `PATH`; `ARC_EXECUTION_BINARY` +/// overrides it with another name or an absolute path. +fn execution_binary() -> Result { + resolve_execution_binary(std::env::var_os(EXECUTION_BINARY_ENV)) +} + +/// Applies the override rules to a raw `ARC_EXECUTION_BINARY` lookup. Split +/// from [`execution_binary`] so every outcome is testable without mutating the +/// process environment. +/// +/// An unset variable falls back to the default. A variable that is set but holds +/// nothing usable is an error rather than a silent fallback, or the operator is +/// left wondering why the override did nothing — a blank entry in an env file is +/// how that happens. +/// +/// The value stays an `OsString`: a path need not be UTF-8, and converting would +/// only add a failure mode for a configuration that works. +fn resolve_execution_binary(value: Option) -> Result { + match value { + None => Ok(OsString::from(DEFAULT_EXECUTION_BINARY)), + Some(binary) if binary.to_string_lossy().trim().is_empty() => eyre::bail!( + "{EXECUTION_BINARY_ENV} is set but empty; unset it to use \ + `{DEFAULT_EXECUTION_BINARY}` from PATH" + ), + Some(binary) => Ok(binary), + } +} + +/// Chooses a staging directory that survives forced target cleanup. +/// +/// The snapshot archives must be downloaded outside both restore targets +/// because `--force` removes those targets before extraction. Prefer a +/// sibling of the execution path, then a sibling of the consensus path, and +/// fail if neither candidate is outside both targets. +fn snapshot_tmp_dir(execution_dir: &Path, consensus_dir: &Path) -> Result { + let execution_candidate = sibling_tmp_dir(execution_dir); + if is_safe_tmp_dir(&execution_candidate, execution_dir, consensus_dir) { + return Ok(execution_candidate); + } + + let consensus_candidate = sibling_tmp_dir(consensus_dir); + if is_safe_tmp_dir(&consensus_candidate, execution_dir, consensus_dir) { + return Ok(consensus_candidate); + } + + eyre::bail!( + "could not derive a snapshot staging directory outside execution and consensus paths" + ) +} + +/// Returns the conventional snapshot staging directory next to `dir`. +/// +/// A sibling rather than a child because the execution restore removes its +/// target: an archive staged inside would be deleted before it could be +/// extracted. `arc-node-consensus download` stages inside its home instead, since +/// the consensus restore never removes anything. +fn sibling_tmp_dir(dir: &Path) -> PathBuf { + dir.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .join(".snapshot-tmp") +} + +/// Returns whether `candidate` is outside both restore target directories. +/// +/// A safe staging directory must not be removed when the execution and +/// consensus directories are deleted during a clean forced restore. +fn is_safe_tmp_dir(candidate: &Path, execution_dir: &Path, consensus_dir: &Path) -> bool { + !candidate.starts_with(execution_dir) && !candidate.starts_with(consensus_dir) +} + #[cfg(test)] mod tests { + use std::ffi::{OsStr, OsString}; + use std::sync::{Arc, Mutex}; + use super::*; + #[derive(Clone)] + struct LogWriter(Arc>>); + + impl std::io::Write for LogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + fn parse(args: &[&str]) -> Result { Cli::try_parse_from(args) } + /// One recorded `arc-node-execution download` invocation. + struct RecordedCall { + binary: OsString, + argv: Vec, + /// Whether the datadir still held state when the call arrived. Proves + /// the forced delete happens before the hand-off, not after. + datadir_had_state: bool, + } + + /// How the fake behaves when a restore reaches it. + #[derive(Debug, Clone, Copy, PartialEq)] + enum FakeOutcome { + /// Probe and download both succeed. + Succeeds, + /// The probe rejects the binary, standing in for one that is missing or + /// too old for a manifest download. + ProbeFails, + /// The probe passes and the download fails partway, leaving behind the + /// partial state reth extracts as it goes. + RunFailsPartway, + /// The download exits zero having written state but never finishing, so + /// no `reth.toml` appears. reth's download returns success when the + /// process is signalled, so a zero exit does not mean it completed. + RunSucceedsWithoutFinishing, + } + + /// Records how the execution binary would have been invoked instead of + /// launching it. + struct RecordingDownloader { + calls: Arc>>, + outcome: FakeOutcome, + } + + impl RecordingDownloader { + fn new(outcome: FakeOutcome) -> (Self, Arc>>) { + let calls = Arc::new(Mutex::new(Vec::new())); + ( + Self { + calls: Arc::clone(&calls), + outcome, + }, + calls, + ) + } + } + + impl manifest::ExecutionDownloader for RecordingDownloader { + fn ensure_available(&self, binary: &OsStr) -> Result<()> { + if self.outcome == FakeOutcome::ProbeFails { + eyre::bail!( + "execution binary `{}` is unusable", + binary.to_string_lossy() + ); + } + Ok(()) + } + + fn run(&self, binary: &OsStr, argv: &[OsString]) -> Result<()> { + let datadir = argv + .iter() + .position(|a| a == "--datadir") + .and_then(|i| argv.get(i.saturating_add(1))) + .map(PathBuf::from); + self.calls.lock().unwrap().push(RecordedCall { + binary: binary.to_os_string(), + argv: argv.to_vec(), + datadir_had_state: datadir + .as_deref() + .is_some_and(download::execution_snapshot_exists), + }); + + if let Some(datadir) = &datadir { + // reth extracts into the datadir as it goes, so any run that got + // started leaves state behind. + std::fs::create_dir_all(datadir.join("db"))?; + std::fs::write(datadir.join("db/mdbx.dat"), b"partial")?; + // ...and writes reth.toml at the end, only once it has finished. + if self.outcome == FakeOutcome::Succeeds { + std::fs::write(datadir.join("reth.toml"), b"[stages]")?; + } + } + + if self.outcome == FakeOutcome::RunFailsPartway { + eyre::bail!("execution download failed"); + } + Ok(()) + } + } + + const TEST_MANIFEST_URL: &str = "http://x.example/testnet/manifest.json"; + const TEST_CONSENSUS_URL: &str = "http://unreachable.invalid/cl.tar.lz4"; + + /// Builds a restore whose consensus layer is already up to date, so nothing + /// in the test reaches the network. + fn offline_restore(root: &Path, profile: ElProfile) -> ManifestRestore { + let execution = root.join("execution"); + let consensus = root.join("consensus"); + std::fs::create_dir_all(&consensus).unwrap(); + std::fs::write(consensus.join("store.db"), b"cl").unwrap(); + download::write_snapshot_version(&consensus, TEST_CONSENSUS_URL).unwrap(); + + ManifestRestore { + chain: Chain::Testnet, + profile, + manifest_url: TEST_MANIFEST_URL.to_string(), + consensus_url: TEST_CONSENSUS_URL.to_string(), + dirs: SnapshotDirs { + execution, + consensus, + tmp: root.join(".snapshot-tmp"), + }, + force_redownload: false, + binary: OsString::from("arc-node-execution"), + } + } + + /// Writes the `db/mdbx.dat` and marker an already-restored datadir has. + fn seed_execution_dir(dir: &Path, marker: &str) { + std::fs::create_dir_all(dir.join("db")).unwrap(); + std::fs::write(dir.join("db/mdbx.dat"), b"state").unwrap(); + download::write_snapshot_version(dir, marker).unwrap(); + } + + #[tokio::test] + async fn manifest_restore_hands_the_manifest_to_the_execution_binary() { + let root = tempfile::tempdir().unwrap(); + let restore = offline_restore(root.path(), ElProfile::Full); + let execution_dir = restore.dirs.execution.clone(); + let (downloader, calls) = RecordingDownloader::new(FakeOutcome::Succeeds); + + run_download_manifest_snapshot(restore, downloader) + .await + .unwrap(); + + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].binary, OsStr::new("arc-node-execution")); + assert_eq!( + calls[0].argv, + [ + OsString::from("download"), + OsString::from("--chain"), + OsString::from("arc-testnet"), + OsString::from("--datadir"), + OsString::from(&execution_dir), + OsString::from("--full"), + OsString::from("--manifest-url"), + OsString::from(TEST_MANIFEST_URL), + ] + ); + assert_eq!( + std::fs::read_to_string(execution_dir.join(".snapshot-url")).unwrap(), + manifest::manifest_marker(TEST_MANIFEST_URL, ElProfile::Full) + ); + } + + #[tokio::test] + async fn explicit_manifest_without_a_profile_uses_minimal() { + let cli = parse(&[ + "arc-snapshots", + "download", + "--chain", + "arc-testnet", + "--execution-url", + TEST_MANIFEST_URL, + "--consensus-url", + TEST_CONSENSUS_URL, + ]) + .unwrap(); + let Commands::Download(args) = cli.command; + let root = tempfile::tempdir().unwrap(); + let restore = offline_restore(root.path(), args.el_profile); + let (downloader, calls) = RecordingDownloader::new(FakeOutcome::Succeeds); + + run_download_manifest_snapshot(restore, downloader) + .await + .unwrap(); + + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert!(calls[0].argv.contains(&OsString::from("--minimal"))); + } + + #[tokio::test] + async fn explicit_archive_url_is_accepted_with_the_default_profile() { + use tracing::instrument::WithSubscriber; + + let root = tempfile::tempdir().unwrap(); + let execution = root.path().join("execution"); + let consensus = root.path().join("consensus"); + let execution_url = "http://unreachable.invalid/el.tar.lz4"; + let consensus_url = "http://unreachable.invalid/cl.tar.lz4"; + std::fs::create_dir_all(execution.join("db")).unwrap(); + std::fs::write(execution.join("db/mdbx.dat"), b"el").unwrap(); + download::write_snapshot_version(&execution, execution_url).unwrap(); + std::fs::create_dir_all(&consensus).unwrap(); + std::fs::write(consensus.join("store.db"), b"cl").unwrap(); + download::write_snapshot_version(&consensus, consensus_url).unwrap(); + let args = DownloadArgs { + execution_url: Some(execution_url.to_string()), + consensus_url: Some(consensus_url.to_string()), + chain: None, + execution_path: Some(execution), + consensus_path: Some(consensus), + force_redownload: false, + el_profile: ElProfile::Minimal, + }; + let bytes = Arc::new(Mutex::new(Vec::new())); + let writer_bytes = Arc::clone(&bytes); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_target(false) + .with_max_level(tracing::Level::WARN) + .with_writer(move || LogWriter(Arc::clone(&writer_bytes))) + .finish(); + + run_download(args) + .with_subscriber(subscriber) + .await + .unwrap(); + + assert!(bytes.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn manifest_restore_skips_the_hand_off_when_the_marker_matches() { + let root = tempfile::tempdir().unwrap(); + let restore = offline_restore(root.path(), ElProfile::Minimal); + seed_execution_dir( + &restore.dirs.execution, + &manifest::manifest_marker(TEST_MANIFEST_URL, ElProfile::Minimal), + ); + let (downloader, calls) = RecordingDownloader::new(FakeOutcome::Succeeds); + + run_download_manifest_snapshot(restore, downloader) + .await + .unwrap(); + + assert!(calls.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn manifest_restore_repeats_the_hand_off_when_the_profile_changes() { + // Same manifest URL, different profile: the component set differs, so + // the restore must run rather than report the datadir as up to date. + let root = tempfile::tempdir().unwrap(); + let restore = offline_restore(root.path(), ElProfile::Archive); + seed_execution_dir( + &restore.dirs.execution, + &manifest::manifest_marker(TEST_MANIFEST_URL, ElProfile::Minimal), + ); + let (downloader, calls) = RecordingDownloader::new(FakeOutcome::Succeeds); + + run_download_manifest_snapshot(restore, downloader) + .await + .unwrap(); + + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert!(calls[0].argv.contains(&OsString::from("--archive"))); + // reth writes in place and only the files its manifest lists, so the + // minimal-shaped datadir must be gone before the archive components + // land on top of it. + assert!( + !calls[0].datadir_had_state, + "a marker mismatch must clear the datadir before reth is invoked" + ); + } + + #[tokio::test] + async fn manifest_restore_writes_no_marker_when_the_hand_off_fails() { + let root = tempfile::tempdir().unwrap(); + let restore = offline_restore(root.path(), ElProfile::Minimal); + let execution_dir = restore.dirs.execution.clone(); + let (downloader, _calls) = RecordingDownloader::new(FakeOutcome::RunFailsPartway); + + let err = run_download_manifest_snapshot(restore, downloader) + .await + .unwrap_err(); + + assert!(err.to_string().contains("execution download failed")); + assert!(!execution_dir.join(".snapshot-url").exists()); + // The datadir is left half-populated, which is the state a rerun has to + // deal with: reth writes in place, so there is nothing to roll back. + assert!(download::execution_snapshot_exists(&execution_dir)); + } + + #[tokio::test] + async fn manifest_restore_records_nothing_when_the_hand_off_stops_early() { + // The child exits zero without finishing, which is what reth does when it + // is signalled. Trusting the exit code would write a marker over part of a + // snapshot, and the next run would then skip the layer as up to date. + let root = tempfile::tempdir().unwrap(); + let restore = offline_restore(root.path(), ElProfile::Minimal); + let execution_dir = restore.dirs.execution.clone(); + let (downloader, calls) = + RecordingDownloader::new(FakeOutcome::RunSucceedsWithoutFinishing); + + let err = run_download_manifest_snapshot(restore, downloader) + .await + .unwrap_err(); + + assert!(err.to_string().contains("reth.toml"), "unexpected: {err}"); + assert_eq!(calls.lock().unwrap().len(), 1, "the hand-off did happen"); + assert!(!execution_dir.join(".snapshot-url").exists()); + } + + #[tokio::test] + async fn manifest_restore_refuses_a_datadir_it_did_not_restore() { + // State and no marker: a node that synced from genesis, a datadir placed + // by hand, or the hand-off above dying partway. Indistinguishable, so the + // run stops and names --force rather than deleting one of them. + let root = tempfile::tempdir().unwrap(); + let restore = offline_restore(root.path(), ElProfile::Minimal); + let execution_dir = restore.dirs.execution.clone(); + std::fs::create_dir_all(execution_dir.join("db")).unwrap(); + std::fs::write(execution_dir.join("db/mdbx.dat"), b"self-synced").unwrap(); + let (downloader, calls) = RecordingDownloader::new(FakeOutcome::Succeeds); + + let err = run_download_manifest_snapshot(restore, downloader) + .await + .unwrap_err(); + + assert!(err.to_string().contains("--force"), "unexpected: {err}"); + assert!(calls.lock().unwrap().is_empty(), "no hand-off may happen"); + assert_eq!( + std::fs::read(execution_dir.join("db/mdbx.dat")).unwrap(), + b"self-synced" + ); + } + + #[tokio::test] + async fn manifest_restore_needs_force_after_an_interrupted_hand_off() { + // A hand-off that dies partway leaves state and no marker. The worst + // outcome would be the next run reporting success over it, so the plain + // rerun stops; --force is how the operator says to redo the restore. + let root = tempfile::tempdir().unwrap(); + let (downloader, _calls) = RecordingDownloader::new(FakeOutcome::RunFailsPartway); + let err = run_download_manifest_snapshot( + offline_restore(root.path(), ElProfile::Minimal), + downloader, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("execution download failed")); + + let restore = offline_restore(root.path(), ElProfile::Minimal); + let execution_dir = restore.dirs.execution.clone(); + assert!(download::execution_snapshot_exists(&execution_dir)); + assert!(!execution_dir.join(".snapshot-url").exists()); + + let (downloader, calls) = RecordingDownloader::new(FakeOutcome::Succeeds); + let err = run_download_manifest_snapshot(restore, downloader) + .await + .unwrap_err(); + assert!(err.to_string().contains("--force"), "unexpected: {err}"); + assert!(calls.lock().unwrap().is_empty()); + + // --force ignores the marker, so the consensus archive is really fetched. + let (_server, consensus_url) = serve_consensus_archive().await; + let mut restore = offline_restore(root.path(), ElProfile::Minimal); + restore.consensus_url = consensus_url; + restore.force_redownload = true; + let (downloader, calls) = RecordingDownloader::new(FakeOutcome::Succeeds); + run_download_manifest_snapshot(restore, downloader) + .await + .unwrap(); + + assert_eq!( + calls.lock().unwrap().len(), + 1, + "--force must redo the restore" + ); + assert_eq!( + std::fs::read_to_string(execution_dir.join(".snapshot-url")).unwrap(), + manifest::manifest_marker(TEST_MANIFEST_URL, ElProfile::Minimal) + ); + } + + #[tokio::test] + async fn manifest_restore_touches_nothing_when_the_probe_fails() { + // --force is what makes this test bite: it is the mode that deletes the + // datadir, so the probe has to reject the binary before that happens. + let root = tempfile::tempdir().unwrap(); + let mut restore = offline_restore(root.path(), ElProfile::Minimal); + restore.force_redownload = true; + let (_server, consensus_url) = serve_consensus_archive().await; + restore.consensus_url = consensus_url; + let execution_dir = restore.dirs.execution.clone(); + let consensus_dir = restore.dirs.consensus.clone(); + seed_execution_dir(&execution_dir, "http://old/manifest.json"); + let (downloader, calls) = RecordingDownloader::new(FakeOutcome::ProbeFails); + + let err = run_download_manifest_snapshot(restore, downloader) + .await + .unwrap_err(); + + assert!(err.to_string().contains("is unusable"), "unexpected: {err}"); + assert!(calls.lock().unwrap().is_empty(), "no hand-off may happen"); + // Both layers are as they were: an unusable binary must cost nothing. + assert_eq!( + std::fs::read(execution_dir.join("db/mdbx.dat")).unwrap(), + b"state" + ); + // The consensus marker, not its contents. The served archive holds the + // same bytes the seed does, so only the marker separates "never + // restored" from "wiped and restored again". + assert_eq!( + std::fs::read_to_string(consensus_dir.join(".snapshot-url")).unwrap(), + TEST_CONSENSUS_URL + ); + } + + #[tokio::test] + async fn manifest_restore_clears_the_datadir_before_the_hand_off_under_force() { + let root = tempfile::tempdir().unwrap(); + let mut restore = offline_restore(root.path(), ElProfile::Minimal); + restore.force_redownload = true; + // --force ignores the marker, so the consensus archive is really + // downloaded; the server has to stay alive for the whole restore. + let (_server, consensus_url) = serve_consensus_archive().await; + restore.consensus_url = consensus_url; + seed_execution_dir(&restore.dirs.execution, "http://old/manifest.json"); + let (downloader, calls) = RecordingDownloader::new(FakeOutcome::Succeeds); + + run_download_manifest_snapshot(restore, downloader) + .await + .unwrap(); + + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert!( + !calls[0].datadir_had_state, + "--force must clear the datadir before reth is invoked" + ); + } + + /// Serves a minimal consensus archive from a local mock server. The server + /// is returned so the caller can keep it alive. + async fn serve_consensus_archive() -> (wiremock::MockServer, String) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let encoder = lz4::EncoderBuilder::new().build(Vec::new()).unwrap(); + let mut builder = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(2); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, "store.db", b"cl".as_ref()) + .unwrap(); + let (body, result) = builder.into_inner().unwrap().finish(); + result.unwrap(); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/cl.tar.lz4")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(body.clone()) + .append_header("Content-Length", body.len().to_string().as_str()), + ) + .mount(&server) + .await; + + let url = format!("{}/cl.tar.lz4", server.uri()); + (server, url) + } + #[test] fn parse_download_with_explicit_paths() { let cli = parse(&[ @@ -186,10 +954,10 @@ mod tests { } #[test] - fn parse_download_chain_default_is_testnet() { + fn parse_download_chain_default_is_none() { let cli = parse(&["arc-snapshots", "download"]).unwrap(); let Commands::Download(args) = cli.command; - assert!(matches!(args.chain, Chain::Testnet)); + assert!(args.chain.is_none()); } #[test] @@ -206,7 +974,44 @@ mod tests { ]) .unwrap(); let Commands::Download(args) = cli.command; - assert!(matches!(args.chain, Chain::Devnet)); + assert!(matches!(args.chain, Some(Chain::Devnet))); + } + + #[test] + fn parse_download_explicit_chain_mainnet() { + let cli = parse(&[ + "arc-snapshots", + "download", + "--chain", + "arc-mainnet", + "--execution-url", + "http://x/el", + "--consensus-url", + "http://x/cl", + ]) + .unwrap(); + let Commands::Download(args) = cli.command; + assert!(matches!(args.chain, Some(Chain::Mainnet))); + } + + #[test] + fn parse_download_no_chain_with_urls_is_ok() { + // Explicit URLs make --chain unnecessary — should parse cleanly. + let cli = parse(&[ + "arc-snapshots", + "download", + "--execution-url", + "http://x/el", + "--consensus-url", + "http://x/cl", + "--execution-path", + "/tmp/el", + "--consensus-path", + "/tmp/cl", + ]) + .unwrap(); + let Commands::Download(args) = cli.command; + assert!(args.chain.is_none()); } #[test] @@ -259,28 +1064,199 @@ mod tests { assert!(!args.force_redownload); } + #[test] + fn parse_download_el_profile() { + let cli = parse(&["arc-snapshots", "download"]).unwrap(); + let Commands::Download(args) = cli.command; + assert_eq!(args.el_profile, ElProfile::Minimal); + + let cli = parse(&["arc-snapshots", "download", "--el-profile", "archive"]).unwrap(); + let Commands::Download(args) = cli.command; + assert_eq!(args.el_profile, ElProfile::Archive); + + let cli = parse(&["arc-snapshots", "download", "--el-profile", "minimal"]).unwrap(); + let Commands::Download(args) = cli.command; + assert_eq!(args.el_profile, ElProfile::Minimal); + + let cli = parse(&["arc-snapshots", "download", "--el-profile", "full"]).unwrap(); + let Commands::Download(args) = cli.command; + assert_eq!(args.el_profile, ElProfile::Full); + } + + #[test] + fn download_help_advertises_the_minimal_profile_default() { + use clap::CommandFactory; + + let mut command = Cli::command(); + let help = command + .find_subcommand_mut("download") + .unwrap() + .render_long_help() + .to_string(); + + assert!( + help.contains("[default: minimal]"), + "unexpected help: {help}" + ); + } + + #[test] + fn resolve_execution_binary_prefers_the_override() { + assert_eq!( + resolve_execution_binary(Some(OsString::from("/opt/arc/bin/arc-node-execution"))) + .unwrap(), + OsStr::new("/opt/arc/bin/arc-node-execution") + ); + } + + #[test] + fn resolve_execution_binary_falls_back_when_unset() { + assert_eq!( + resolve_execution_binary(None).unwrap(), + OsStr::new(DEFAULT_EXECUTION_BINARY) + ); + } + + #[test] + fn resolve_execution_binary_rejects_an_empty_override() { + // A blank env-file entry sets the variable without giving it a value. + // Falling back silently would leave the operator wondering why their + // override did nothing, and `Command::new("")` reports the binary as not + // found — advising them to set a variable they did set. + for blank in ["", " "] { + let err = resolve_execution_binary(Some(OsString::from(blank))) + .unwrap_err() + .to_string(); + assert!(err.contains("ARC_EXECUTION_BINARY"), "unexpected: {err}"); + assert!(err.contains("set but empty"), "unexpected: {err}"); + } + } + + #[test] + fn resolve_execution_binary_accepts_a_non_utf8_path() { + // Paths are not required to be UTF-8, so an OsString override is passed + // through rather than rejected. + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + let path = OsString::from_vec(b"/opt/arc/\xff/arc-node-execution".to_vec()); + assert_eq!(resolve_execution_binary(Some(path.clone())).unwrap(), path); + } + } + + #[test] + fn snapshot_tmp_dir_uses_execution_parent() { + let execution_dir = Path::new("/tmp/arc/execution"); + let consensus_dir = Path::new("/tmp/arc/consensus"); + let tmp_dir = snapshot_tmp_dir(execution_dir, consensus_dir).unwrap(); + + assert_eq!(tmp_dir, PathBuf::from("/tmp/arc/.snapshot-tmp")); + assert!(!tmp_dir.starts_with(execution_dir)); + assert!(!tmp_dir.starts_with(consensus_dir)); + } + + #[test] + fn snapshot_tmp_dir_avoids_consensus_target() { + let execution_dir = Path::new("/tmp/arc/execution"); + let consensus_dir = Path::new("/tmp/arc"); + let tmp_dir = snapshot_tmp_dir(execution_dir, consensus_dir).unwrap(); + + assert_eq!(tmp_dir, PathBuf::from("/tmp/.snapshot-tmp")); + assert!(!tmp_dir.starts_with(execution_dir)); + assert!(!tmp_dir.starts_with(consensus_dir)); + } + + #[test] + fn snapshot_tmp_dir_errors_when_candidates_conflict() { + let execution_dir = Path::new("/tmp/.snapshot-tmp"); + let consensus_dir = Path::new("/tmp/.snapshot-tmp"); + let err = snapshot_tmp_dir(execution_dir, consensus_dir).unwrap_err(); + + assert!(err.to_string().contains("could not derive")); + } + #[tokio::test] - async fn run_download_errors_with_only_one_url() { + async fn run_download_rejects_lone_consensus_url() { let args = DownloadArgs { execution_url: None, consensus_url: Some("http://x/cl".into()), - chain: Chain::Devnet, + chain: Some(Chain::Devnet), + execution_path: Some("/tmp/el".into()), + consensus_path: Some("/tmp/cl".into()), + force_redownload: false, + el_profile: ElProfile::Minimal, + }; + let err = run_download(args).await.unwrap_err(); + assert!(err.to_string().contains("requires --execution-url")); + } + + #[tokio::test] + async fn run_download_rejects_lone_execution_url_without_chain() { + let args = DownloadArgs { + execution_url: Some("http://x/el".into()), + consensus_url: None, + chain: None, execution_path: Some("/tmp/el".into()), consensus_path: Some("/tmp/cl".into()), force_redownload: false, + el_profile: ElProfile::Minimal, }; let err = run_download(args).await.unwrap_err(); - assert!(err.to_string().contains("both")); + assert_eq!( + err.to_string(), + "--execution-url requires --consensus-url; omit both to resolve a matched pair" + ); + } + #[tokio::test] + async fn run_download_rejects_lone_execution_url_with_chain() { let args = DownloadArgs { execution_url: Some("http://x/el".into()), consensus_url: None, - chain: Chain::Devnet, + chain: Some(Chain::Devnet), + execution_path: Some("/tmp/el".into()), + consensus_path: Some("/tmp/cl".into()), + force_redownload: false, + el_profile: ElProfile::Minimal, + }; + let err = run_download(args).await.unwrap_err(); + assert_eq!( + err.to_string(), + "--execution-url requires --consensus-url; omit both to resolve a matched pair" + ); + } + + #[tokio::test] + async fn run_download_errors_with_no_chain_and_no_urls() { + let args = DownloadArgs { + execution_url: None, + consensus_url: None, + chain: None, + execution_path: Some("/tmp/el".into()), + consensus_path: Some("/tmp/cl".into()), + force_redownload: false, + el_profile: ElProfile::Minimal, + }; + let err = run_download(args).await.unwrap_err(); + assert!(err.to_string().contains("--chain is required")); + } + + #[tokio::test] + async fn run_download_manifest_requires_chain_even_with_urls() { + // A manifest execution URL needs --chain even when both URLs are given — + // arc-node-execution picks its chainspec from it. A single archive would + // not, so this specifically exercises the manifest path. + let args = DownloadArgs { + execution_url: Some("http://x/manifest.json".into()), + consensus_url: Some("http://x/cl".into()), + chain: None, execution_path: Some("/tmp/el".into()), consensus_path: Some("/tmp/cl".into()), force_redownload: false, + el_profile: ElProfile::Full, }; let err = run_download(args).await.unwrap_err(); - assert!(err.to_string().contains("both")); + assert!(err.to_string().contains("--chain is required")); } } diff --git a/crates/snapshots/src/manifest.rs b/crates/snapshots/src/manifest.rs new file mode 100644 index 00000000..fb2f7eb9 --- /dev/null +++ b/crates/snapshots/src/manifest.rs @@ -0,0 +1,463 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Manifest snapshot restore. +//! +//! A manifest restore delegates the execution layer to the +//! `arc-node-execution download` command (reth's `DownloadCommand`), while the +//! consensus layer keeps arc-snapshots' own archive download. + +use std::{ + ffi::{OsStr, OsString}, + io, + path::Path, + process::{Command, Stdio}, +}; + +use eyre::Result; + +use crate::download::{url_identity, Chain}; + +/// Execution-layer component set for a manifest restore. +/// +/// Maps to `arc-node-execution download`'s `--minimal` / `--full` / `--archive` +/// presets. Has no effect for a caller-supplied single archive, whose contents +/// determine the data restored. +#[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)] +pub enum ElProfile { + /// State + all headers + small unwind buffer. + #[value(name = "minimal")] + Minimal, + /// Full transactions, receipts, and changesets. + #[value(name = "full")] + Full, + /// Every component, incl. transaction senders and rocksdb indices. + #[value(name = "archive")] + Archive, +} + +impl ElProfile { + /// The `arc-node-execution download` preset flag for this profile. + fn flag(self) -> &'static str { + match self { + Self::Minimal => "--minimal", + Self::Full => "--full", + Self::Archive => "--archive", + } + } +} + +impl std::fmt::Display for ElProfile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Minimal => write!(f, "minimal"), + Self::Full => write!(f, "full"), + Self::Archive => write!(f, "archive"), + } + } +} + +/// The `.snapshot-url` marker value for a completed manifest restore. +/// +/// The profile is part of the identity, not just the URL: the same manifest +/// restored as `minimal` and as `archive` produce different datadirs, so a +/// profile change must not read as an up-to-date restore. +/// +/// The URL contributes only its [`url_identity`], so a re-signed pre-signed URL +/// does not read as a different snapshot. Composing after stripping also keeps +/// the profile suffix out of reach of a second normalization pass. +pub fn manifest_marker(manifest_url: &str, profile: ElProfile) -> String { + format!("{} el-profile={profile}", url_identity(manifest_url)) +} + +/// Inputs for an execution-layer download delegated to +/// `arc-node-execution download`. +pub struct ExecutionDownload<'a> { + /// Target chain; supplies the `--chain` value via [`Chain::arc_chain_arg`]. + pub chain: Chain, + /// Component preset to fetch. + pub profile: ElProfile, + /// Target reth datadir, passed verbatim as `--datadir`. + pub datadir: &'a Path, + /// Manifest URL, passed as `--manifest-url`. Content-based dispatch only + /// reaches this path when a manifest URL is known, so it is always present. + pub manifest_url: &'a str, +} + +/// The flag that carries the manifest URL to `arc-node-execution download`. +/// +/// Shared between the argv and the pre-flight check, so the probe verifies the +/// flag the restore actually passes. +/// +/// reth's `DownloadCommand` is what names it. Should that name change, every +/// manifest restore fails the probe with "needs a newer execution binary", which is +/// the wrong explanation but at least stops before deleting anything. +const MANIFEST_URL_FLAG: &str = "--manifest-url"; + +/// The file reth leaves behind once a manifest download has finished. +/// +/// `reth.toml` is reth's config file, written by `write_config`. That is the +/// first thing that reth does after every selected snapshot-manifest archive has +/// been downloaded and verified, so finding the file means the download got to the +/// end. +/// +/// That holds only because [`clear_datadir`] empties the datadir immediately +/// before the download runs. A node that has been running already has a +/// `reth.toml` of its own, and reth's `write_config` leaves an existing file alone +/// instead of replacing it. So without the wipe, that old file would still be +/// there after a download that stopped halfway, and the check would read it as a +/// success. Anything that stops wiping the datadir — making the download +/// resumable, say — needs a different completion signal. +const COMPLETION_FILE: &str = "reth.toml"; + +/// Empties the execution datadir so a manifest restore starts from nothing. +/// +/// `arc-node-execution download` writes only the files its manifest lists, and +/// straight into the datadir — there is no staging step. Anything already there +/// survives and mixes with the new data, so it has to go first, which also means +/// a restore that fails leaves the datadir empty. +pub fn clear_datadir(datadir: &Path) -> Result<()> { + crate::download::remove_restore_dir(datadir) +} + +/// Build the argument vector for `arc-node-execution download` from `opts`. +/// +/// `--force` is intentionally absent: reth's download has no such flag, so the +/// manifest path achieves a clean restore by wiping the datadir before +/// invoking. +pub fn build_execution_argv(opts: &ExecutionDownload) -> Vec { + vec![ + "download".into(), + "--chain".into(), + opts.chain.arc_chain_arg().into(), + "--datadir".into(), + opts.datadir.into(), + opts.profile.flag().into(), + MANIFEST_URL_FLAG.into(), + opts.manifest_url.into(), + ] +} + +/// Runs the `arc-node-execution download` command. +/// +/// This is a trait so tests can swap in a fake that records how it was +/// called instead of actually launching the binary. +pub trait ExecutionDownloader { + /// Fail unless `binary` can carry out a manifest download. + /// + /// Called before a restore deletes anything. The execution datadir is + /// removed before reth writes into it, so this has to establish more than + /// "the binary starts": a build predating reth's `download` command, or + /// predating its manifest support, would pass that and still fail after the + /// data was gone. + fn ensure_available(&self, binary: &OsStr) -> Result<()>; + + /// Run `binary` with the given arguments. Returns an error if the binary + /// can't be started or exits with a failure code. + fn run(&self, binary: &OsStr, argv: &[OsString]) -> Result<()>; +} + +/// The [`ExecutionDownloader`] used in real runs. It launches the binary as a +/// child process, passes the child's output straight through to the terminal +/// (so you see the download progress), and waits for it to finish. +pub struct CommandDownloader; + +impl ExecutionDownloader for CommandDownloader { + fn ensure_available(&self, binary: &OsStr) -> Result<()> { + let name = binary.to_string_lossy(); + // `download --help` rather than `--version`: it asks the binary whether + // it has the subcommand this restore needs. clap prints every long flag + // in its help output, so the presence of the flag we pass is checkable + // from the same invocation. + let output = Command::new(binary) + .args(["download", "--help"]) + .stderr(Stdio::null()) + .output() + .map_err(|e| spawn_error(binary, e))?; + + if !output.status.success() { + eyre::bail!( + "`{name} download` is unavailable (exited with {}); a manifest \ + restore needs an execution binary that carries reth's download command", + output.status + ); + } + if !String::from_utf8_lossy(&output.stdout).contains(MANIFEST_URL_FLAG) { + eyre::bail!( + "`{name} download` does not accept {MANIFEST_URL_FLAG}; a manifest \ + restore needs a newer execution binary" + ); + } + Ok(()) + } + + fn run(&self, binary: &OsStr, argv: &[OsString]) -> Result<()> { + let status = Command::new(binary) + .args(argv) + .status() + .map_err(|e| spawn_error(binary, e))?; + if !status.success() { + let name = binary.to_string_lossy(); + eyre::bail!("`{name} download` exited with {status}"); + } + Ok(()) + } +} + +/// Turns a failure to launch `binary` into a user-facing error. +fn spawn_error(binary: &OsStr, e: io::Error) -> eyre::Report { + let binary = binary.to_string_lossy(); + if e.kind() == io::ErrorKind::NotFound { + eyre::eyre!( + "execution binary `{binary}` not found on PATH; \ + set ARC_EXECUTION_BINARY to its path" + ) + } else { + eyre::eyre!("failed to run `{binary}`: {e}") + } +} + +/// Build the download argv from `opts` and run it via `downloader`. +pub fn run_execution_download( + downloader: &dyn ExecutionDownloader, + binary: &OsStr, + opts: &ExecutionDownload, +) -> Result<()> { + downloader.run(binary, &build_execution_argv(opts))?; + + if !opts.datadir.join(COMPLETION_FILE).exists() { + eyre::bail!( + "`{} download` exited successfully but left no {COMPLETION_FILE} in {}, \ + so the download did not finish and the datadir holds only part of the \ + snapshot", + binary.to_string_lossy(), + opts.datadir.display() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + + /// Records each invocation instead of spawning a process. + struct RecordingDownloader { + calls: RefCell)>>, + } + + impl RecordingDownloader { + fn new() -> Self { + Self { + calls: RefCell::new(Vec::new()), + } + } + } + + impl ExecutionDownloader for RecordingDownloader { + fn ensure_available(&self, _binary: &OsStr) -> Result<()> { + Ok(()) + } + + fn run(&self, binary: &OsStr, argv: &[OsString]) -> Result<()> { + self.calls + .borrow_mut() + .push((binary.to_os_string(), argv.to_vec())); + let datadir = argv + .iter() + .position(|a| a == "--datadir") + .and_then(|i| argv.get(i.saturating_add(1))) + .map(Path::new) + .expect("argv always carries --datadir"); + std::fs::create_dir_all(datadir)?; + std::fs::write(datadir.join(COMPLETION_FILE), b"[stages]")?; + Ok(()) + } + } + + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|&s| OsString::from(s)).collect() + } + + #[test] + fn run_execution_download_passes_expected_argv() { + let recorder = RecordingDownloader::new(); + let dir = tempfile::tempdir().unwrap(); + let datadir = dir.path().join("execution"); + let opts = ExecutionDownload { + chain: Chain::Devnet, + profile: ElProfile::Full, + datadir: &datadir, + manifest_url: "https://x.example/m.json", + }; + run_execution_download(&recorder, OsStr::new("arc-node-execution"), &opts).unwrap(); + + let calls = recorder.calls.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, OsStr::new("arc-node-execution")); + assert_eq!( + calls[0].1, + argv(&[ + "download", + "--chain", + "arc-devnet", + "--datadir", + datadir.to_str().unwrap(), + "--full", + "--manifest-url", + "https://x.example/m.json", + ]) + ); + } + + #[test] + fn run_execution_download_rejects_a_zero_exit_that_wrote_no_config() { + // reth's download runs under a ctrl-c runner that returns success when the + // process is signalled, so a zero exit can mean "abandoned partway". The + // config file it writes at the end is what separates the two. + struct SilentSuccess; + impl ExecutionDownloader for SilentSuccess { + fn ensure_available(&self, _binary: &OsStr) -> Result<()> { + Ok(()) + } + fn run(&self, _binary: &OsStr, _argv: &[OsString]) -> Result<()> { + Ok(()) + } + } + + let dir = tempfile::tempdir().unwrap(); + let datadir = dir.path().join("execution"); + let opts = ExecutionDownload { + chain: Chain::Devnet, + profile: ElProfile::Full, + datadir: &datadir, + manifest_url: "https://x.example/m.json", + }; + + let err = run_execution_download(&SilentSuccess, OsStr::new("arc-node-execution"), &opts) + .unwrap_err(); + assert!( + err.to_string().contains(COMPLETION_FILE), + "unexpected: {err}" + ); + } + + #[test] + fn command_downloader_errors_on_nonzero_exit() { + // `false` is a POSIX utility that always exits non-zero. + let err = CommandDownloader.run(OsStr::new("false"), &[]).unwrap_err(); + assert!(err.to_string().contains("exited with"), "unexpected: {err}"); + } + + #[test] + fn ensure_available_rejects_a_binary_without_the_download_command() { + // `false` stands in for a build predating reth's download command: it + // launches, and rejects the subcommand. + let err = CommandDownloader + .ensure_available(OsStr::new("false")) + .unwrap_err(); + assert!( + err.to_string().contains("download` is unavailable"), + "unexpected: {err}" + ); + } + + #[test] + fn ensure_available_rejects_a_binary_without_manifest_support() { + // `true` stands in for a build whose download command predates manifests: + // the subcommand is accepted, but the flag this restore passes is absent + // from its help. + let err = CommandDownloader + .ensure_available(OsStr::new("true")) + .unwrap_err(); + assert!( + err.to_string().contains(MANIFEST_URL_FLAG), + "unexpected: {err}" + ); + } + + #[test] + fn ensure_available_reports_a_missing_binary() { + // The message must name the override, since this check is what stops a + // forced restore from deleting data it cannot replace. + let err = CommandDownloader + .ensure_available(OsStr::new("arc-node-execution-does-not-exist")) + .unwrap_err(); + assert!( + err.to_string().contains("not found on PATH"), + "unexpected: {err}" + ); + assert!( + err.to_string().contains("ARC_EXECUTION_BINARY"), + "unexpected: {err}" + ); + } + + #[test] + fn manifest_marker_records_the_url_and_the_profile() { + let url = "https://x.example/testnet/manifest.json"; + assert_eq!( + manifest_marker(url, ElProfile::Minimal), + "https://x.example/testnet/manifest.json el-profile=minimal" + ); + } + + #[test] + fn flag_matches_the_clap_value_name() { + // `flag` restates the #[value(name = ...)] strings as `--`, and the + // two have to agree: the name is what an operator passes to + // --el-profile, the flag is what reaches arc-node-execution. Iterating + // value_variants() covers a new profile automatically. + use clap::ValueEnum; + + for profile in ElProfile::value_variants() { + let value = profile.to_possible_value().unwrap(); + assert_eq!(profile.flag(), format!("--{}", value.get_name())); + } + } + + #[test] + fn manifest_marker_ignores_a_pre_signed_signature() { + // Otherwise a re-signed URL reads as a different snapshot and the whole + // execution layer is fetched again. + let clean = "https://x.example/testnet/manifest.json"; + let signed = "https://x.example/testnet/manifest.json?X-Amz-Signature=deadbeef"; + assert_eq!( + manifest_marker(signed, ElProfile::Full), + manifest_marker(clean, ElProfile::Full) + ); + assert!(!manifest_marker(signed, ElProfile::Full).contains("Signature")); + } + + #[test] + fn manifest_marker_differs_per_profile() { + // A profile change must not read as an up-to-date restore, so the same + // manifest URL has to produce a different marker per profile. + let url = "https://x.example/testnet/manifest.json"; + let markers = [ + manifest_marker(url, ElProfile::Minimal), + manifest_marker(url, ElProfile::Full), + manifest_marker(url, ElProfile::Archive), + ]; + for (i, a) in markers.iter().enumerate() { + for b in markers.iter().skip(i.saturating_add(1)) { + assert_ne!(a, b); + } + } + } +} diff --git a/crates/spammer/Cargo.toml b/crates/spammer/Cargo.toml index 19b3b855..d801e34d 100644 --- a/crates/spammer/Cargo.toml +++ b/crates/spammer/Cargo.toml @@ -34,6 +34,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } tokio-tungstenite = "0.28.0" tracing = { workspace = true } diff --git a/crates/spammer/Dockerfile b/crates/spammer/Dockerfile index 4fec15b5..a93e91b8 100644 --- a/crates/spammer/Dockerfile +++ b/crates/spammer/Dockerfile @@ -41,9 +41,14 @@ RUN cargo clean RUN --mount=type=cache,sharing=private,target=/usr/local/cargo/registry \ --mount=type=cache,sharing=private,target=/usr/local/cargo/git \ --mount=type=cache,sharing=private,target=/app/target \ - # Build the Spammer binary. + --mount=type=secret,id=github_token,required=false \ + set -eu; \ + if [ -s /run/secrets/github_token ]; then \ + token="$(cat /run/secrets/github_token)"; \ + git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"; \ + trap 'git config --global --remove-section url."https://${token}@github.com/" || true' EXIT; \ + fi; \ cargo build -p spammer --release && \ - # Copy the built binary to a temporary, non-cached location to make it accessible to the next stage. cp /app/target/release/spammer /tmp/spammer ########### diff --git a/crates/spammer/README.md b/crates/spammer/README.md index eaaf8021..32c9db1d 100644 --- a/crates/spammer/README.md +++ b/crates/spammer/README.md @@ -251,6 +251,8 @@ The following parameters are shared across both `spammer ws` and `spammer nodes` Output the status of all Reth tx-pools (number of pending and queued transactions) [aliases: --pools] -w, --wait-response Wait for the response from Reth after sending a transaction + --silent + Suppress all log output (gates the tracing subscriber) ``` ### GasGuzzler workload diff --git a/crates/spammer/src/accounts.rs b/crates/spammer/src/accounts.rs index 58985de8..593c0280 100644 --- a/crates/spammer/src/accounts.rs +++ b/crates/spammer/src/accounts.rs @@ -18,9 +18,10 @@ use alloy_signer_local::{coins_bip39::English, LocalSigner, MnemonicBuilder}; use clap::{builder::PossibleValue, ValueEnum}; use color_eyre::eyre::{self, Context, Result}; use k256::ecdsa::SigningKey; +use serde::{Deserialize, Serialize}; use strum_macros::EnumString; -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub(crate) struct AccountBuilder { mnemonic: String, } diff --git a/crates/spammer/src/cli.rs b/crates/spammer/src/cli.rs index 044a34f0..e55fa2a0 100644 --- a/crates/spammer/src/cli.rs +++ b/crates/spammer/src/cli.rs @@ -131,12 +131,42 @@ pub struct SpammerArgs { /// Can only be used when `--tx-latency` is enabled. #[clap(long, requires = "tx_latency", global = true)] pub csv_dir: Option, + /// Path to write the run's summary JSON. + /// + /// When set, the spammer writes a `SpammerSummary` (timestamps, offered + /// TPS, offered bytes/s, RPC error counts) to the given path at the end + /// of the run. Consumed by orchestrators (e.g. `quake run saturation`) + /// to recover phase metrics without parsing stdout. Independent of + /// `--csv-dir` / `--tx-latency`. + #[clap(long, global = true)] + pub summary_json: Option, + /// Path to write the captured `SpammerState` as JSON at the end of the run. + /// + /// Lets a later spammer invocation resume against the same accounts via + /// `--state-in` without re-deriving BIP32 keys or re-querying nonces. + /// Used by `quake run saturation` remote mode to hand state from one + /// phase's subprocess to the next. + #[clap(long, global = true)] + pub state_out: Option, + /// Path to read a previously-written `SpammerState` JSON to resume from. + /// + /// When set, the spammer skips fresh account initialisation and + /// reconstructs its generators from the saved state. The accompanying + /// `--num-generators` and partition mode must match the saved state. + #[clap(long, global = true)] + pub state_in: Option, /// Wait for the response from Reth after sending a transaction /// /// If false, the output will only show the number of transactions sent and its length in bytes. /// If true, the output will additionally show error messages if any, but it will slow down the execution. #[clap(short = 'w', long, default_value = "false", global = true)] pub wait_response: bool, + /// Suppress all log output (gates the tracing subscriber). + /// + /// Set automatically by `quake -q load`/`spam` so the remote spammer + /// stays quiet; can also be passed directly to the `spammer` binary. + #[clap(long, default_value = "false", global = true)] + pub silent: bool, /// Number of reconnection attempts when a connection fails #[clap(long, default_value_t = defaults::RECONNECT_ATTEMPTS, global = true)] pub reconnect_attempts: u32, @@ -230,7 +260,29 @@ impl SpammerArgs { bool_flag!(args, "-l", self.query_latest_nonce); bool_flag!(args, "-p", self.show_pool_status); bool_flag!(args, "--tx-latency", self.tx_latency); + if let Some(ref dir) = self.csv_dir { + args.extend(["--csv-dir".to_string(), dir.to_string_lossy().into_owned()]); + } + if let Some(ref path) = self.summary_json { + args.extend([ + "--summary-json".to_string(), + path.to_string_lossy().into_owned(), + ]); + } + if let Some(ref path) = self.state_out { + args.extend([ + "--state-out".to_string(), + path.to_string_lossy().into_owned(), + ]); + } + if let Some(ref path) = self.state_in { + args.extend([ + "--state-in".to_string(), + path.to_string_lossy().into_owned(), + ]); + } bool_flag!(args, "-w", self.wait_response); + bool_flag!(args, "--silent", self.silent); flag!( args, @@ -373,7 +425,11 @@ mod tests { show_pool_status: false, tx_latency: false, csv_dir: None, + summary_json: None, + state_out: None, + state_in: None, wait_response: false, + silent: false, reconnect_attempts: defaults::RECONNECT_ATTEMPTS, reconnect_period: defaults::RECONNECT_PERIOD, tx_type_mix: None, @@ -408,6 +464,19 @@ mod tests { assert!(cli.contains(&"-i".to_string())); } + #[test] + fn to_cli_args_silent_only_emitted_when_set() { + assert!(!default_args() + .to_cli_args() + .contains(&"--silent".to_string())); + + let args = SpammerArgs { + silent: true, + ..default_args() + }; + assert!(args.to_cli_args().contains(&"--silent".to_string())); + } + #[test] fn to_cli_args_with_mix() { let args = SpammerArgs { diff --git a/crates/spammer/src/config.rs b/crates/spammer/src/config.rs index 531276e6..1cab34b4 100644 --- a/crates/spammer/src/config.rs +++ b/crates/spammer/src/config.rs @@ -18,9 +18,10 @@ use std::path::PathBuf; use crate::accounts::PartitionMode; use color_eyre::eyre::{self, Result}; +use serde::{Deserialize, Serialize}; use std::str::FromStr; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum GuzzlerFunction { #[default] HashLoop, @@ -30,13 +31,13 @@ pub enum GuzzlerFunction { Guzzle2, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GuzzlerFnConfig { pub weight: u32, pub arg: u64, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct GuzzlerFnWeights { pub hash_loop: GuzzlerFnConfig, pub storage_write: GuzzlerFnConfig, @@ -145,7 +146,7 @@ impl FromStr for GuzzlerFnWeights { } /// ERC-20 function the spammer can call. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Erc20Function { #[default] Transfer, @@ -158,7 +159,7 @@ pub enum Erc20Function { /// Parsed from a comma-separated string such as `transfer=70,approve=20,transfer-from=10`. /// Weights are ratios. When all weights are 0 (the default), the generator /// defaults to 100% transfer for backward compatibility. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Erc20FnWeights { pub transfer: u32, pub approve: u32, @@ -214,7 +215,7 @@ impl FromStr for Erc20FnWeights { } /// Transaction type the spammer can generate. -#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub enum TxType { /// Native USDC value transfer between prefunded accounts (EIP-1559, Type 2). #[default] @@ -232,7 +233,7 @@ pub enum TxType { /// Parsed from a comma-separated string such as `transfer=70,erc20=20,guzzler=10`. /// Weights are ratios, not percentages, so `transfer=2,erc20=1` produces ~67% transfers /// and ~33% ERC-20 calls. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TxTypeMix { pub transfer: u32, pub legacy: u32, diff --git a/crates/spammer/src/generator.rs b/crates/spammer/src/generator.rs index 84cf19aa..b57b40e1 100644 --- a/crates/spammer/src/generator.rs +++ b/crates/spammer/src/generator.rs @@ -22,12 +22,35 @@ use alloy_sol_types::{sol, SolCall}; use color_eyre::eyre::{self, Result}; use k256::ecdsa::SigningKey; use rand::Rng; +use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::{HashMap, HashSet}; use std::ops::Range; -use tokio::sync::mpsc::Sender; +use tokio::sync::mpsc::{error::TryRecvError, Receiver, Sender}; use tracing::{debug, info, warn}; +/// Outcome of a single fire-and-forget tx submission as observed by the sender. +/// +/// Sent back from `TxSender` to `TxGenerator` over a dedicated channel so the +/// generator can refresh the cached nonce on rejection. `Accepted` doesn't +/// carry an account index because the generator already optimistically +/// advanced its cache at submit time — the variant is kept so the sender +/// can still emit "successfully observed" outcomes for diagnostic logging +/// without forcing every caller to discriminate between "didn't drain" and +/// "drained Ok". +#[derive(Debug, Clone, Copy)] +pub(crate) enum AckOutcome { + /// Node accepted the tx into its mempool — no-op for the generator + /// (cache was already advanced optimistically). + Accepted, + /// Node rejected the tx (any failure, transient or terminal). The + /// payload is the **account index** whose tx was rejected; the generator + /// uses it to refresh that account's cached nonce from chain so the next + /// submission for that account uses the correct value, which corrects + /// any optimistic overshoot. + Rejected(usize), +} + use crate::accounts::AccountBuilder; use crate::config::{ Erc20FnWeights, Erc20Function, GuzzlerFnWeights, GuzzlerFunction, TxType, TxTypeMix, @@ -61,6 +84,14 @@ pub(crate) const GAS_CACHE_BUFFER: u64 = 2; pub(crate) const GAS_ESTIMATE_MARGIN_NUM: u64 = 5; pub(crate) const GAS_ESTIMATE_MARGIN_DEN: u64 = 4; +/// Number of parallel workers used by `resync_nonces` to fan out per-account +/// nonce queries. Each worker holds its own WS client set, so the total +/// connection count per node is `RESYNC_CONCURRENCY × num_builders`, kept +/// small enough to avoid the HTTP 429 storm that motivated the original +/// single-WS-client design (50k accounts × N builders would otherwise open +/// 50k+ parallel connections). +const RESYNC_CONCURRENCY: usize = 8; + /// Generates and signs transactions from a pool of pre-funded genesis accounts. /// /// Each generator is assigned a non-overlapping slice of the account space and @@ -80,23 +111,40 @@ pub(crate) const GAS_ESTIMATE_MARGIN_DEN: u64 = 4; /// In fire-and-forget mode the generator pushes signed transactions into a /// channel for a separate [`TxSender`](crate::sender::TxSender) task; in backpressure mode the sender /// owns the generator directly and calls [`next_tx`](Self::next_tx). +#[derive(Serialize, Deserialize)] pub(crate) struct TxGenerator { id: usize, + /// BIP32-derived signing keys. Skipped on (de)serialise — keys are a + /// deterministic function of `account_builder.mnemonic` + account index, + /// so the lazy `if signers[i].is_none() { build }` path in `next_tx` + /// reconstructs them on first use. `ensure_signers_capacity` is called + /// from `Spammer::new_resuming` to size the Vec to `signers_range.len()` + /// before any per-account access happens. + #[serde(skip, default)] signers: Vec>>, signers_range: Range, next_nonces: Vec>, account_builder: AccountBuilder, + /// Skipped — repopulated by `update_ws_client_builders` on resume. + #[serde(skip, default)] ws_client_builders: Vec, - /// Channel to send signed txs to a separate `TxSender` task (fire-and-forget mode). + /// Channel to send `(signed_tx, account_index)` pairs to a separate + /// `TxSender` task (fire-and-forget mode). The account_index is plumbed + /// through so the sender can correlate ack/refresh outcomes back to a + /// specific account via the per-client inflight deques. /// `None` in backpressure mode, where the sender owns the generator directly. - tx_sender: Option>, + /// Skipped — repopulated by `reset_tx_sender` on resume. + #[serde(skip, default)] + tx_sender: Option>, max_txs_per_account: u64, query_latest_nonce: bool, tx_input_size: usize, guzzler_fn_weights: GuzzlerFnWeights, erc20_fn_weights: Erc20FnWeights, tx_type_mix: TxTypeMix, - /// Lazily built WS clients (used by next_tx for guzzler gas estimation and nonce queries) + /// Lazily built WS clients (used by next_tx for guzzler gas estimation and nonce queries). + /// Skipped — rebuilt on demand from `ws_client_builders` on resume. + #[serde(skip, default)] ws_clients: Option>, /// Whether the GasGuzzler contract has been verified as deployed guzzler_verified: bool, @@ -123,7 +171,7 @@ impl TxGenerator { signers_range: Range, account_builder: AccountBuilder, ws_client_builders: Vec, - tx_sender: Option>, + tx_sender: Option>, max_txs_per_account: u64, query_latest_nonce: bool, tx_input_size: usize, @@ -211,7 +259,7 @@ impl TxGenerator { } /// Replace the tx channel sender, used when reusing a generator across phases. - pub(crate) fn reset_tx_sender(&mut self, sender: Sender) { + pub(crate) fn reset_tx_sender(&mut self, sender: Sender<(TxEnvelope, usize)>) { self.tx_sender = Some(sender); // Clear stale connections so init() rebuilds them on the next run. self.ws_clients = None; @@ -225,8 +273,47 @@ impl TxGenerator { self.ws_clients = None; } + /// Size the `signers` Vec to `signers_range.len()`, inserting `None` + /// placeholders. Called after deserialising a persisted + /// [`SpammerState`](crate::SpammerState) so the lazy per-account + /// derivation in `next_tx`/`next_signed_tx` has indexable slots to fill + /// on first use. No-op for in-process resumes where `signers` is already + /// populated. + pub(crate) fn ensure_signers_capacity(&mut self) { + let size = self.signers_range.len(); + if self.signers.len() != size { + self.signers = vec![None; size]; + } + } + + /// True when every account in `signers_range` already has a derived key. + /// Used by [`SpammerState::eagerly_derive_signers`] to short-circuit the + /// `spawn_blocking` fan-out for in-process resumes. + pub(crate) fn signers_populated(&self) -> bool { + self.signers.len() == self.signers_range.len() && self.signers.iter().all(Option::is_some) + } + + /// Pre-derive every BIP32 signing key for this generator's account range, + /// matching the warm-cache that local-mode `SpammerState` has after + /// ramp-up. Pure CPU work; called via `tokio::task::spawn_blocking` from + /// [`SpammerState::eagerly_derive_signers`] so the runtime can parallelise + /// across generators. No-op when keys are already populated. + pub(crate) fn eagerly_derive_signers(&mut self) -> Result<()> { + if self.signers_populated() { + return Ok(()); + } + let size = self.signers_range.len(); + let start = self.signers_range.start; + let mut signers = Vec::with_capacity(size); + for i in 0..size { + signers.push(Some(self.account_builder.build(start + i)?)); + } + self.signers = signers; + Ok(()) + } + async fn build_ws_clients(&self) -> Result> { - let mut ws_clients = Vec::new(); + let mut ws_clients = Vec::with_capacity(self.ws_client_builders.len()); for builder in self.ws_client_builders.iter().cloned() { ws_clients.push(builder.build().await?); } @@ -586,7 +673,12 @@ impl TxGenerator { /// Re-query on-chain nonces for all accounts and overwrite cached values. /// /// Uses `eth_getTransactionCount` with "pending" to skip nonces already - /// accepted by the pool. + /// accepted by the pool. The account range is partitioned across + /// `RESYNC_CONCURRENCY` worker tasks, each owning its own dedicated set + /// of WS clients — total connections per node stay bounded + /// (`RESYNC_CONCURRENCY × num_builders`, e.g. 8 × 10 = 80) while restoring + /// N-way parallelism inside resync. Per-account queries within a worker + /// remain serial (one in-flight per WS client). pub(crate) async fn resync_nonces(&mut self) -> Result<()> { let size = self.signers_range.len(); info!( @@ -601,28 +693,43 @@ impl TxGenerator { } } - let mut handles: Vec>> = - Vec::with_capacity(size); - for i in 0..size { - let address = self.signers[i].as_ref().expect("built above").address(); - let cached = self.next_nonces[i]; - let builders = self.ws_client_builders.clone(); + let workers = RESYNC_CONCURRENCY.min(size).max(1); + let mut worker_clients = Vec::with_capacity(workers); + for _ in 0..workers { + worker_clients.push(self.build_ws_clients().await?); + } + + let chunk = size.div_ceil(workers); + let mut handles = Vec::with_capacity(workers); + for (w, mut clients) in worker_clients.into_iter().enumerate() { + let start = (w * chunk).min(size); + let end = ((w + 1) * chunk).min(size); + if start >= end { + continue; + } + let addresses: Vec<(usize, Address, Option)> = (start..end) + .map(|i| { + ( + i, + self.signers[i].as_ref().expect("built above").address(), + self.next_nonces[i], + ) + }) + .collect(); handles.push(tokio::spawn(async move { - let mut ws_clients = { - let mut clients = Vec::with_capacity(builders.len()); - for builder in builders { - clients.push(builder.build().await?); - } - clients - }; - Self::fetch_nonce(&mut ws_clients, address, cached) - .await - .map(|nonce| (i, nonce)) + let mut results = Vec::with_capacity(addresses.len()); + for (i, address, cached) in addresses { + let nonce = Self::fetch_nonce(&mut clients, address, cached).await?; + results.push((i, nonce)); + } + Ok::, eyre::Error>(results) })); } - for handle in handles { - let (i, nonce) = handle.await??; - self.next_nonces[i] = Some(nonce); + + for h in handles { + for (i, nonce) in h.await?? { + self.next_nonces[i] = Some(nonce); + } } info!("TxGenerator {}: nonce resync complete", self.id); @@ -646,7 +753,19 @@ impl TxGenerator { } /// Generate transactions and send them to the load scheduler (fire-and-forget mode). - pub async fn run(&mut self) -> Result<()> { + /// + /// Nonces are still advanced optimistically right after the channel push: + /// without that, the generator's tight loop would build several txs at + /// the same nonce before the sender's first response came back, and the + /// chain would reject every duplicate as `"nonce too low"`. The + /// `ack_rx` channel from the paired `TxSender` is used purely as a + /// *correction* signal — `AckOutcome::Rejected` triggers a chain-side + /// `refresh_nonce` for the affected account, which catches the cases the + /// optimistic ack overshoots (response-stream errors like + /// `"nonce too low"`, `"txpool is full"`, validation failures, etc.). + /// `AckOutcome::Accepted` is therefore a no-op — the cache was already + /// advanced at submit time. + pub async fn run(&mut self, mut ack_rx: Receiver) -> Result<()> { debug!("TxGenerator {}: running...", self.id); let tx_sender = self @@ -656,23 +775,72 @@ impl TxGenerator { .clone(); loop { + // Drain any pending acks (just refreshes — accepts are no-ops) + // before generating the next tx. Done synchronously via + // `try_recv` to keep the borrow checker happy — `next_tx().await` + // borrows `&mut self` mutably, and we need the same borrow + // inside the refresh handler, so the two cannot overlap in a + // `select!` arm. + self.process_pending_acks(&mut ack_rx).await; + match self.next_tx().await? { Some((signed_tx, account_index)) => { - if tx_sender.send(signed_tx).await.is_err() { + if tx_sender.send((signed_tx, account_index)).await.is_err() { // Channel closed, abort return Ok(()); } - // Fire-and-forget: optimistically ack nonce after channel push + // Optimistic ack: advance cache so the next iteration's + // `next_tx` uses nonce+1. Sender-driven refresh on + // rejection corrects the overshoot if the tx is rejected. self.ack_nonce(account_index); } None => { - // All accounts exhausted + // All accounts exhausted — drain any remaining acks so + // late-arriving rejections still trigger refresh. + self.process_pending_acks(&mut ack_rx).await; return Ok(()); } } } } + /// Drain whatever `AckOutcome` messages are immediately available. + /// `Accepted` is a no-op (the generator already advanced the cache + /// optimistically at submit time). `Rejected` flags the account as + /// dirty so any overshoot is corrected before the next tx for that + /// account is built. Refreshes happen after the drain completes, + /// deduplicated per account — a burst of inflight rejections for the + /// same account (e.g. several stale-nonce txs queued behind a real + /// rejection) costs one chain query, not N. + /// Non-blocking: returns as soon as `try_recv` yields `Empty`. A + /// `Disconnected` channel is treated as terminal — the sender side + /// has exited and there's nothing more to ack. + async fn process_pending_acks(&mut self, ack_rx: &mut Receiver) { + let mut dirty: HashSet = HashSet::new(); + loop { + match ack_rx.try_recv() { + Ok(AckOutcome::Accepted) => {} + Ok(AckOutcome::Rejected(idx)) => { + dirty.insert(idx); + } + Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break, + } + } + for idx in dirty { + // If the chain query fails, leave the cached (advanced) nonce in + // place and log. The account will keep emitting txs with the + // stale optimistic nonce until the next rejection drives another + // refresh attempt. Worst case it converges at the next phase-end + // resync, which re-queries every account from chain. + if let Err(e) = self.refresh_nonce(idx).await { + debug!( + "TxGenerator {}: refresh_nonce failed for account {idx}: {e}", + self.id + ); + } + } + } + /// Ensure a signer is initialized at the given index, returning a reference. fn ensure_signer(&mut self, index: usize) -> Result<&LocalSigner> { if self.signers[index].is_none() { @@ -909,10 +1077,51 @@ mod tests { use std::{collections::HashMap, time::Duration}; use tokio::sync::mpsc; + /// Whether the committed localdev genesis allocates code at `addr`. + fn localdev_genesis_allocates(addr: Address) -> bool { + let genesis_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../assets/localdev/genesis.json" + ); + let raw = std::fs::read_to_string(genesis_path) + .unwrap_or_else(|e| panic!("failed to read {genesis_path}: {e}")); + let genesis: serde_json::Value = + serde_json::from_str(&raw).expect("localdev genesis.json must be valid JSON"); + genesis["alloc"] + .as_object() + .expect("localdev genesis must have an 'alloc' object") + .keys() + .filter_map(|k| k.parse::
().ok()) + .any(|a| a == addr) + } + + /// The spammer's load contracts are reached at fixed, hardcoded addresses; the localdev + /// genesis must deploy them there. This guards against the genesis builder placing them at + /// build-dependent (drifting) addresses that no longer match these constants. + #[test] + fn guzzler_address_present_in_localdev_genesis() { + assert!( + localdev_genesis_allocates(GUZZLER_ADDRESS), + "GasGuzzler must be allocated at {GUZZLER_ADDRESS} in the localdev genesis so the \ + guzzler mix can reach it; regenerate the genesis with GasGuzzler pinned to the \ + canonical address" + ); + } + + #[test] + fn test_token_address_present_in_localdev_genesis() { + assert!( + localdev_genesis_allocates(TEST_TOKEN_ADDRESS), + "TestToken must be allocated at {TEST_TOKEN_ADDRESS} in the localdev genesis so the \ + erc20 mix can reach it; regenerate the genesis with TestToken pinned to the \ + canonical address" + ); + } + fn make_generator( start: usize, end: usize, - tx_sender: Option>, + tx_sender: Option>, max_txs_per_account: u64, ) -> TxGenerator { let account_builder = AccountBuilder::new(TEST_MNEMONIC.to_string()); @@ -937,6 +1146,51 @@ mod tests { ) } + #[test] + fn tx_generator_round_trips_through_json() { + // Build a generator, populate the persistable state, serialise, + // deserialise, and verify it round-trips. `signers` is `#[serde(skip)]` + // — keys are re-derived lazily via `next_tx`, with capacity restored + // by `ensure_signers_capacity` (exercised below). + let mut tg = make_generator(5, 10, None, 0); + for i in 0..5 { + tg.next_nonces[i] = Some(i as u64 + 42); + tg.tx_counts[i] = (i as u64 + 1) * 7; + } + tg.next_account_index = 3; + tg.skipped_accounts.insert(2); + tg.guzzler_verified = true; + tg.test_token_verified = true; + tg.erc20_gas_cache.insert(Erc20Function::Transfer, 21_000); + tg.guzzler_gas_cache + .insert(GuzzlerFunction::HashLoop, 100_000); + + let json = serde_json::to_string(&tg).expect("serialise"); + let mut restored: TxGenerator = serde_json::from_str(&json).expect("deserialise"); + + assert_eq!(restored.id, tg.id); + assert_eq!(restored.signers_range, tg.signers_range); + assert_eq!(restored.next_nonces, tg.next_nonces); + assert_eq!(restored.tx_counts, tg.tx_counts); + assert_eq!(restored.next_account_index, tg.next_account_index); + assert_eq!(restored.skipped_accounts, tg.skipped_accounts); + assert_eq!(restored.guzzler_verified, tg.guzzler_verified); + assert_eq!(restored.test_token_verified, tg.test_token_verified); + assert_eq!(restored.erc20_gas_cache, tg.erc20_gas_cache); + assert_eq!(restored.guzzler_gas_cache, tg.guzzler_gas_cache); + // Skipped fields default on deserialise — signers, channels, WS clients. + assert!(restored.signers.is_empty()); + assert!(restored.tx_sender.is_none()); + assert!(restored.ws_client_builders.is_empty()); + assert!(restored.ws_clients.is_none()); + + // ensure_signers_capacity resizes to one slot per account in range, + // each holding None until next_tx lazily derives the BIP32 key. + restored.ensure_signers_capacity(); + assert_eq!(restored.signers.len(), restored.signers_range.len()); + assert!(restored.signers.iter().all(Option::is_none)); + } + #[tokio::test] async fn tx_generator_distributes_across_signers() -> Result<()> { let account_builder = AccountBuilder::new(TEST_MNEMONIC.to_string()); @@ -951,11 +1205,16 @@ mod tests { (900, 1000, 1000), ]; for (start, end, channel_capacity) in test_cases { - let (tx_sender, mut tx_receiver) = mpsc::channel::(channel_capacity); + let (tx_sender, mut tx_receiver) = + mpsc::channel::<(TxEnvelope, usize)>(channel_capacity); + // The generator no longer acks optimistically — the sender drives + // ack/refresh via a back-channel. The test only exercises tx + // distribution, so wire up an ack receiver that's never written to. + let (_ack_tx, ack_rx) = mpsc::channel::(channel_capacity); let mut generator = make_generator(start, end, Some(tx_sender), 0); // When we run the generator briefly to fill up the channel - let handle = tokio::spawn(async move { generator.run().await }); + let handle = tokio::spawn(async move { generator.run(ack_rx).await }); tokio::time::sleep(Duration::from_millis(channel_capacity as u64)).await; handle.abort(); // to stop producing more txs let _ = handle.await; // ignore join errors from abort @@ -963,7 +1222,7 @@ mod tests { // Drain generated txs from channel and count txs per signer (by recovered sender address) let mut per_sender_counts: HashMap = HashMap::new(); let mut counter = 0usize; - while let Ok(envelope) = tx_receiver.try_recv() { + while let Ok((envelope, _account_index)) = tx_receiver.try_recv() { let sender = envelope.recover_signer().expect("recover signer"); *per_sender_counts.entry(sender).or_default() += 1; counter += 1; diff --git a/crates/spammer/src/lib.rs b/crates/spammer/src/lib.rs index e96eb7ed..9ad589a9 100644 --- a/crates/spammer/src/lib.rs +++ b/crates/spammer/src/lib.rs @@ -34,5 +34,5 @@ mod ws; pub use accounts::PartitionMode; pub use cli::SpammerArgs; -pub use config::{Config, ResumeConfig}; -pub use spammer::{Spammer, SpammerRunResult, SpammerState}; +pub use config::{Config, Erc20FnWeights, GuzzlerFnWeights, ResumeConfig, TxTypeMix}; +pub use spammer::{Spammer, SpammerRunResult, SpammerState, SpammerSummary}; diff --git a/crates/spammer/src/main.rs b/crates/spammer/src/main.rs index 802a6244..64bc3ae8 100644 --- a/crates/spammer/src/main.rs +++ b/crates/spammer/src/main.rs @@ -20,7 +20,7 @@ clippy::unwrap_used )] -use std::{collections::HashMap, fs, io::IsTerminal}; +use std::{collections::HashMap, fs, io::IsTerminal, io::Write, path::Path}; use clap::{Parser, Subcommand}; use clap_verbosity_flag::{InfoLevel, Verbosity}; @@ -96,21 +96,25 @@ enum TargetCommand { async fn main() -> Result<()> { let cli = Cli::parse(); - // Initialize tracing - let level = cli.verbosity.tracing_level_filter(); - let filter = EnvFilter::builder() - .with_default_directive(level.into()) - .from_env()? - .add_directive("hyper_util::client=info".parse()?) - .add_directive("arc_node_consensus_cli::new=info".parse()?); - let subscriber = tracing_subscriber::fmt() - .with_max_level(level) - .with_ansi(std::io::stdout().is_terminal()) - .with_env_filter(filter) - .finish(); - - tracing::subscriber::set_global_default(subscriber) - .context("Failed to set tracing subscriber")?; + let silent = cli.args.silent || cli.verbosity.is_silent(); + + // Initialize tracing unless running silently. + if !silent { + let level = cli.verbosity.tracing_level_filter(); + let filter = EnvFilter::builder() + .with_default_directive(level.into()) + .from_env()? + .add_directive("hyper_util::client=info".parse()?) + .add_directive("arc_node_consensus_cli::new=info".parse()?); + let subscriber = tracing_subscriber::fmt() + .with_max_level(level) + .with_ansi(std::io::stdout().is_terminal()) + .with_env_filter(filter) + .finish(); + + tracing::subscriber::set_global_default(subscriber) + .context("Failed to set tracing subscriber")?; + } tracing::info!( version = arc_version::GIT_VERSION, @@ -118,9 +122,7 @@ async fn main() -> Result<()> { "Spammer starting" ); - let config = cli - .args - .to_config(cli.verbosity.is_silent(), cli.fire_and_forget); + let config = cli.args.to_config(silent, cli.fire_and_forget); config.validate()?; // Build the WebSocket URLs of the target nodes @@ -138,12 +140,95 @@ async fn main() -> Result<()> { } }; - let spammer = Spammer::new(target_ws_urls, &config).await?; - spammer.run().await?; + let summary_json_path = cli.args.summary_json.clone(); + let state_out_path = cli.args.state_out.clone(); + let state_in_path = cli.args.state_in.clone(); + + // Backpressure mode moves every TxGenerator into its TxSender, so + // `Spammer::run_capturing_state` returns an empty `SpammerState`. + // Writing that to --state-out would let a downstream --state-in + // "succeed" as a silent no-op run, hiding the upstream misconfig + // (see `new_resuming` for the matching guard). + if state_out_path.is_some() && !cli.fire_and_forget { + eyre::bail!( + "--state-out requires --fire-and-forget; backpressure mode cannot capture generator state" + ); + } + + // If `--state-in` was provided, resume from the persisted SpammerState and + // skip fresh account initialisation. Otherwise spin up generators normally. + let spammer = if let Some(path) = state_in_path { + let json = fs::read_to_string(&path) + .wrap_err_with(|| format!("Failed to read --state-in from {}", path.display()))?; + let state: spammer::SpammerState = serde_json::from_str(&json) + .wrap_err_with(|| format!("Failed to parse --state-in from {}", path.display()))?; + let resume_config = spammer::ResumeConfig::from(&config); + Spammer::new_resuming(target_ws_urls, state, &resume_config).await? + } else { + Spammer::new(target_ws_urls, &config).await? + }; + let result = spammer.run_capturing_state().await?; + + // Drop a JSON summary at the caller-provided path. Used by orchestrators + // (e.g. `quake run saturation`) to recover phase metrics without parsing + // spammer stdout. Independent of the latency CSV (`--csv-dir`). + if let Some(path) = summary_json_path { + let summary = spammer::SpammerSummary::from(&result); + let json = + serde_json::to_string_pretty(&summary).wrap_err("Failed to serialise summary JSON")?; + write_atomic(&json, &path, "summary-json")?; + } + + // Persist the captured SpammerState so a subsequent `--state-in` invocation + // can resume without re-initialising accounts. + if let Some(path) = state_out_path { + let json = serde_json::to_string(&result.state) + .wrap_err("Failed to serialise SpammerState JSON")?; + write_atomic(&json, &path, "state-out")?; + } Ok(()) } +/// Write `contents` to `path` atomically: stage into a tempfile in the target +/// directory and rename on success. The `label` is included in error messages +/// so a failed `--state-out` write isn't confused with a failed +/// `--summary-json` write. +fn write_atomic(contents: &str, path: &Path, label: &str) -> Result<()> { + let parent = path.parent().filter(|p| !p.as_os_str().is_empty()); + if let Some(p) = parent { + fs::create_dir_all(p) + .wrap_err_with(|| format!("Failed to create parent dir {} for {label}", p.display()))?; + } + // Tempfile must be on the same filesystem as the destination for + // rename to be atomic; using the parent dir guarantees that. + let dir = parent.unwrap_or_else(|| Path::new(".")); + let mut tmp = tempfile::NamedTempFile::new_in(dir) + .wrap_err_with(|| format!("Failed to create tempfile for {label} in {}", dir.display()))?; + tmp.write_all(contents.as_bytes()) + .wrap_err_with(|| format!("Failed to write {label} tempfile"))?; + // tempfile defaults to mode 0o600 (owner-only). Remote orchestrators + // SCP these files as an unprivileged user from a path the spammer + // writes as root (inside the docker wrapper on CC), so 0o600 makes the + // file unreadable. Match the broader-readable default the prior + // non-atomic `fs::write` produced via umask. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tmp.as_file() + .set_permissions(std::fs::Permissions::from_mode(0o644)) + .wrap_err_with(|| format!("Failed to chmod {label} tempfile"))?; + } + tmp.persist(path).map_err(|e| { + eyre::eyre!( + "Failed to persist {label} to {}: {}", + path.display(), + e.error + ) + })?; + Ok(()) +} + // Build the WebSocket URLs of the target nodes from the list of IP addresses and ports fn ws_urls_from_strings(target_nodes: Vec) -> Vec<(String, Url)> { target_nodes diff --git a/crates/spammer/src/result_tracker.rs b/crates/spammer/src/result_tracker.rs index e1531617..b4965b52 100644 --- a/crates/spammer/src/result_tracker.rs +++ b/crates/spammer/src/result_tracker.rs @@ -24,9 +24,14 @@ use tokio::time::{self, Duration, Instant}; use crate::ws::{WsClient, WsClientBuilder}; +/// Per-submission outcome: the byte count is always known at submit time, so +/// it's carried alongside both Ok and Err so `total_bytes` accumulates over +/// every locally-offered transaction — not just the ones the server accepted. +pub(crate) type TxOutcome = (u64, Result<()>); + pub(crate) struct ResultTracker { ws_clients: Vec, - result_receiver: Receiver>, + result_receiver: Receiver, finish_receiver: Receiver<()>, silent: bool, show_pool_status: bool, @@ -47,7 +52,7 @@ pub(crate) struct ResultSummary { impl ResultTracker { pub async fn new( ws_client_builders: Vec, - result_receiver: Receiver>, + result_receiver: Receiver, finish_receiver: Receiver<()>, silent: bool, show_pool_status: bool, @@ -88,8 +93,10 @@ impl ResultTracker { // Update counters res = self.result_receiver.recv() => { match res { - Some(Ok(tx_length)) => stats.incr_ok(tx_length), - Some(Err(error)) => stats.incr_err(&error.to_string()), + Some((tx_length, Ok(()))) => stats.incr_ok(tx_length), + Some((tx_length, Err(error))) => { + stats.incr_err(tx_length, &error.to_string()) + } None => break, } } @@ -116,7 +123,7 @@ impl ResultTracker { println!("{}", stats.total_display()); Ok(ResultSummary { errors: stats.total_errors, - total_sent: stats.total_succeed, + total_sent: stats.total_sent, total_bytes: stats.total_bytes, elapsed: start_time.elapsed(), }) @@ -149,6 +156,7 @@ struct Stats { succeed: u64, bytes: u64, errors_counter: HashMap, + total_sent: u64, total_succeed: u64, total_bytes: u64, total_errors: HashMap, @@ -161,6 +169,7 @@ impl Stats { succeed: 0, bytes: 0, errors_counter: HashMap::new(), + total_sent: 0, total_succeed: 0, total_bytes: 0, total_errors: HashMap::new(), @@ -170,11 +179,14 @@ impl Stats { fn incr_ok(&mut self, tx_length: u64) { self.succeed += 1; self.bytes += tx_length; + self.total_sent += 1; self.total_succeed += 1; self.total_bytes += tx_length; } - fn incr_err(&mut self, error: &str) { + fn incr_err(&mut self, tx_length: u64, error: &str) { + self.total_sent += 1; + self.total_bytes += tx_length; let category = categorize_error(error); self.errors_counter .entry(category.clone()) @@ -195,10 +207,12 @@ impl Stats { fn total_display(&self) -> String { let elapsed = self.start_time.elapsed().as_millis() as f64 / 1000f64; let mut stats = String::new(); - let tps = self.total_succeed as f64 / elapsed; // since the start of the load + // Mirror `ResultSummary` semantics: "sent" means every offered tx + // (accepted + rejected), matching `total_bytes` on the same line. + let tps = self.total_sent as f64 / elapsed; stats += &format!( "{:>7.3}s: Total sent {:>5} txs ({:>6} bytes), {:>4.1} tx/s", - elapsed, self.total_succeed, self.total_bytes, tps + elapsed, self.total_sent, self.total_bytes, tps ); for (error, count) in self.total_errors.iter() { stats += &format!("\n - \x1b[31m{count} failed\x1b[0m with \"{error}\""); @@ -240,7 +254,47 @@ fn categorize_error(error: &str) -> String { #[cfg(test)] mod tests { - use super::categorize_error; + use super::{categorize_error, Stats}; + use tokio::time::Instant; + + #[test] + fn incr_ok_accumulates_sent_and_bytes() { + let mut stats = Stats::new(Instant::now()); + stats.incr_ok(100); + stats.incr_ok(50); + assert_eq!(stats.total_sent, 2); + assert_eq!(stats.total_succeed, 2); + assert_eq!(stats.total_bytes, 150); + assert!(stats.total_errors.is_empty()); + } + + #[test] + fn incr_err_accumulates_sent_and_bytes_without_succeed() { + let mut stats = Stats::new(Instant::now()); + stats.incr_err(80, "Server Error -32003: txpool is full"); + stats.incr_err(0, "connection closed"); + // total_sent counts every submission; total_bytes carries the byte + // count even when the submission was rejected. total_succeed stays + // pinned to zero because nothing was accepted. + assert_eq!(stats.total_sent, 2); + assert_eq!(stats.total_succeed, 0); + assert_eq!(stats.total_bytes, 80); + assert_eq!(stats.total_errors.values().sum::(), 2); + } + + #[test] + fn mixed_ok_and_err_offered_metrics_count_everything() { + let mut stats = Stats::new(Instant::now()); + stats.incr_ok(100); + stats.incr_err(40, "Server Error -32003: txpool is full"); + stats.incr_ok(60); + stats.incr_err(0, "connection closed"); + // 4 submissions, 2 accepted, 200 bytes attempted (100 + 40 + 60 + 0). + assert_eq!(stats.total_sent, 4); + assert_eq!(stats.total_succeed, 2); + assert_eq!(stats.total_bytes, 200); + assert_eq!(stats.total_errors.values().sum::(), 2); + } #[test] fn nonce_too_low_collapses_across_distinct_nonces() { diff --git a/crates/spammer/src/sender.rs b/crates/spammer/src/sender.rs index 62a61e90..3a7c9655 100644 --- a/crates/spammer/src/sender.rs +++ b/crates/spammer/src/sender.rs @@ -46,15 +46,16 @@ use alloy_consensus::TxEnvelope; use alloy_eips::eip2718::Encodable2718; use color_eyre::eyre::{self, Result, WrapErr}; use serde_json::json; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use tokio::sync::mpsc::{Receiver, Sender}; use tokio::time::{Duration, Instant}; use tracing::{debug, warn}; -use crate::generator::TxGenerator; +use crate::generator::{AckOutcome, TxGenerator}; use crate::latency::{compute_tx_hash, timestamp_now, TxSubmitted}; use crate::rate_limiter::RateLimiter; +use crate::result_tracker::TxOutcome; use crate::ws::{is_connection_error, WsClient, WsClientBuilder}; /// Configuration for `TxSender` behavior. @@ -77,9 +78,12 @@ pub(crate) struct TxSenderConfig { /// The transaction source determines the sender's operating mode. pub(crate) enum TxSource { - /// Fire-and-forget: receives txs from a separate generator task via channel. + /// Fire-and-forget: receives `(tx, account_index)` from a separate + /// generator task via channel. `account_index` is required so the + /// sender can pair each tx with the eventual `AckOutcome` it sends + /// back through `ack_sender`. Channel { - rx: Receiver, + rx: Receiver<(TxEnvelope, usize)>, wait_response: bool, }, /// Backpressure: owns the generator directly, waits for each response. @@ -104,7 +108,12 @@ pub(crate) struct TxSender { /// WebSocket clients used to dispatch transactions to nodes in round-robin. ws_clients: Vec, tx_source: TxSource, - result_sender: Sender>, + result_sender: Sender, + /// Channel for relaying per-tx outcomes back to the paired generator so + /// it can ack the nonce on acceptance or refresh on rejection. Only + /// populated in fire-and-forget mode (`TxSource::Channel`) — backpressure + /// mode owns the generator directly and acks inline. + ack_sender: Option>, /// Optional channel for emitting tx submission timestamps. latency_sender: Option>, rate_limiter: Arc, @@ -124,13 +133,38 @@ enum SendOutcome { Transient(String), } +/// Outcome of a single fire-and-forget `send()` call (named after the +/// `TxSource::Channel` variant that drives this code path). +/// +/// In fire-and-forget mode the sender needs the caller to know whether the +/// tx was already fully observed (`WaitedAccepted` / `WaitedRejected` — +/// `wait_response=true`), is in flight awaiting an async response (`Fired` — +/// `wait_response=false`), or never made it to the wire (`DispatchFailed`). +/// Only the `Fired` variant adds an entry to the per-client inflight deque; +/// the other three are handled inline. Each variant determines whether/when +/// the caller emits an `AckOutcome` to the generator. +enum ChannelSendOutcome { + /// Response already received in-band and was successful. + WaitedAccepted, + /// Response already received in-band and was an error. + WaitedRejected, + /// Tx is on the wire; response will arrive later on the WS stream and + /// must be paired back via the per-client inflight deque. + Fired { request_id: u64, node_idx: usize }, + /// Dispatch reported the error to the tracker without producing a wire + /// frame (already-tracked failure). Caller should treat as a rejection. + DispatchFailed, +} + impl TxSender { /// Create a sender in channel (fire-and-forget mode). + #[allow(clippy::too_many_arguments)] pub async fn new_channel( id: usize, ws_client_builders: Vec, - tx_receiver: Receiver, - result_sender: Sender>, + tx_receiver: Receiver<(TxEnvelope, usize)>, + result_sender: Sender, + ack_sender: Sender, rate_limiter: Arc, config: TxSenderConfig, ) -> Result { @@ -143,6 +177,7 @@ impl TxSender { wait_response: config.wait_response, }, result_sender, + ack_sender: Some(ack_sender), latency_sender: config.latency_sender, rate_limiter, node_index: 0, @@ -157,7 +192,7 @@ impl TxSender { id: usize, ws_client_builders: Vec, generator: TxGenerator, - result_sender: Sender>, + result_sender: Sender, rate_limiter: Arc, config: TxSenderConfig, ) -> Result { @@ -167,6 +202,7 @@ impl TxSender { ws_clients, tx_source: TxSource::Backpressure(Box::new(generator)), result_sender, + ack_sender: None, latency_sender: config.latency_sender, rate_limiter, node_index: 0, @@ -191,33 +227,157 @@ impl TxSender { } } - /// Fire-and-forget mode: read from channel, send, optionally wait for response. + /// Fire-and-forget mode: read from channel, send, optionally wait for + /// response. Tracks every in-flight tx as `(request_id → account_index)` + /// per WS client so when the response eventually drains we can pair it + /// back to the originating account and tell the generator to ack or + /// refresh the nonce. + /// + /// Without this correlation, fire-and-forget mode silently drifted the + /// generator's cached nonce whenever a tx was lost between channel and + /// node (WS reconnect, RPC timeout, ...) — the cache had already been + /// optimistically advanced but no tx with that nonce ever reached chain, + /// so subsequent submissions for the account piled into the queued + /// sub-pool. Pairing drained responses with account_indices closes that + /// loop in real time at the cost of two `usize`s of bookkeeping per + /// in-flight tx. async fn run_channel(&mut self) -> Result<()> { - debug!("TxSender {}: running (channel mode)...", self.id); + debug!("TxSender {}: running (fire-and-forget mode)...", self.id); let wait_response = match &self.tx_source { TxSource::Channel { wait_response, .. } => *wait_response, TxSource::Backpressure(_) => unreachable!("run_channel called in backpressure mode"), }; + // Per-client FIFO of in-flight `(request_id, account_index)` waiting + // for a response. JSON-RPC over WebSocket preserves request order per + // connection, so popping the front on each drained response correctly + // pairs it with the corresponding tx. + let mut inflight: Vec> = (0..self.ws_clients.len()) + .map(|_| VecDeque::new()) + .collect(); let start_time = Instant::now(); loop { if !self.rate_limiter.wait().await { break; } - let tx = match &mut self.tx_source { + let next = match &mut self.tx_source { TxSource::Channel { rx, .. } => rx.recv().await, _ => unreachable!(), }; - if let Some(tx) = tx { - self.send(tx, wait_response).await?; - if !wait_response { - for client in &mut self.ws_clients { - if let Some(result) = client.drain_one_result().await { - let _ = self.result_sender.send(result).await; + let Some((tx, account_index)) = next else { + break; + }; + + let outcome = self.send(tx, wait_response).await?; + + // Buffer ack-outcome work and per-client error-forwarding here + // so we can run the drain loop with `&mut self.ws_clients` and + // emit on `self.ack_sender` / `self.result_sender` once it ends, + // sidestepping the overlapping-borrow lint. + let mut pending_acks: Vec = Vec::new(); + let mut pending_errors: Vec = Vec::new(); + + match outcome { + ChannelSendOutcome::WaitedAccepted => { + pending_acks.push(AckOutcome::Accepted); + } + ChannelSendOutcome::WaitedRejected => { + pending_acks.push(AckOutcome::Rejected(account_index)); + } + ChannelSendOutcome::Fired { + request_id, + node_idx, + } => { + inflight[node_idx].push_back((request_id, account_index)); + } + ChannelSendOutcome::DispatchFailed => { + // dispatch_raw_tx already reported the error to the + // tracker; treat as a rejection so the next tx for this + // account refreshes its nonce. + pending_acks.push(AckOutcome::Rejected(account_index)); + } + } + + // Drain whatever asynchronous responses are available right now. + // Per-client FIFO assumption: responses arrive in the same order + // the requests were issued, so popping the deque front gives us + // the originating account. + for (client_idx, client) in self.ws_clients.iter_mut().enumerate() { + while let Some((resp_id, result)) = client.drain_one_result().await { + // resp_id == 0 marks a connection-level event (e.g. close + // frame) with no originating request. The connection is + // gone and `reconnect()` resets `next_id` to 1, so every + // entry currently in this client's inflight deque has a + // stale id that can never match a future response and + // would corrupt id-pairing if left behind. Drain the deque + // and Reject each pending account so the generator + // refreshes their nonces from chain. + if resp_id == 0 { + if let Err(e) = result { + pending_errors.push(e); } + for (_stale_id, acct) in inflight[client_idx].drain(..) { + pending_acks.push(AckOutcome::Rejected(acct)); + } + continue; + } + let acct = match inflight[client_idx].pop_front() { + Some((expected_id, acct)) if expected_id == resp_id => Some(acct), + Some((expected_id, acct)) => { + // Per-client JSON-RPC over WS is FIFO so a + // mismatch implies a request was silently dropped + // upstream (or a stale entry slipped past a + // reconnect drain). Don't attribute this response + // to the front account — that would emit an + // Ack/Reject against the wrong account index. + // Drop the mismatched front entry too so the deque + // doesn't keep poisoning subsequent matches; the + // affected accounts catch up at the next phase + // resync. + debug!( + "TxSender {}: response id {resp_id} did not match expected {expected_id} on client {client_idx} (dropped front entry for account {acct})", + self.id + ); + None + } + None => { + // Response with no matching inflight entry. + // Close-frame sentinels are handled above; this + // arm only fires if the deque has been drained + // out from under us (shouldn't happen given the + // single-producer-per-client invariant). Skip. + None + } + }; + // Emit an AckOutcome only when we have a confident + // account match. Forward errors to result_tracker + // unconditionally so phase-level `rpc_errors` still picks + // them up — these are two separate sinks (generator vs + // tracker). Successful responses were already counted at + // submit time by `send()`, so we only emit Accepted to + // the generator (cache ack) and skip forwarding to the + // tracker to avoid double-counting. + match (acct, &result) { + (Some(_acct), Ok(())) => { + pending_acks.push(AckOutcome::Accepted); + } + (Some(acct), Err(_)) => { + pending_acks.push(AckOutcome::Rejected(acct)); + } + (None, _) => {} + } + if let Err(e) = result { + pending_errors.push(e); } } - } else { - break; + } + + for ack in pending_acks { + self.emit_ack(ack).await; + } + for err in pending_errors { + // Connection-level / transport errors aren't tied to a specific + // tx, so pass 0 bytes; only counts as a sent submission. + let _ = self.result_sender.send((0, Err(err))).await; } if self.max_time > 0 && start_time.elapsed().as_secs() >= self.max_time { @@ -230,6 +390,14 @@ impl TxSender { Ok(()) } + /// Best-effort emit on the ack channel. A dropped receiver (generator + /// has exited) silently no-ops rather than aborting the sender. + async fn emit_ack(&self, outcome: AckOutcome) { + if let Some(ack) = &self.ack_sender { + let _ = ack.send(outcome).await; + } + } + /// Backpressure mode: generate tx, send, wait for response, ack nonce on success. /// On rejection, re-query the node for the correct nonce before retrying. /// After `MAX_CONSECUTIVE_FAILURES` consecutive rejections for the same @@ -339,30 +507,57 @@ impl TxSender { /// Fire-and-forget send: dispatch and optionally wait for the response. /// + /// Returns a [`ChannelSendOutcome`] so the caller knows whether the + /// response has already been seen (and thus an `AckOutcome` can be + /// emitted inline) or is still in flight (and thus the request_id + /// should be parked in the per-client inflight deque until the async + /// drain pairs it with a response). + /// /// If latency tracking is enabled, records the submission only when the /// result is successful. With `wait_response` enabled, this means only /// transactions accepted by the node are tracked. Without it, the node's - /// response is not checked, so rejected transactions may still be tracked. - async fn send(&mut self, tx: TxEnvelope, wait_response: bool) -> Result<()> { + /// response is not checked synchronously here, so all dispatched txs are + /// tracked optimistically at submit time and the async drain may later + /// surface errors against the same tx. + async fn send(&mut self, tx: TxEnvelope, wait_response: bool) -> Result { // Capture timestamp before sending for accurate latency measurement let submitted_time = timestamp_now(); let (request_id, node_idx, tx_len, tx_hash) = self.dispatch_raw_tx(tx).await?; if request_id == 0 { - return Ok(()); + return Ok(ChannelSendOutcome::DispatchFailed); } - let result = if wait_response { - self.ws_clients[node_idx] + if wait_response { + let response = self.ws_clients[node_idx] .wait_for_response(request_id) - .await - .map(|_: String| tx_len) + .await; + let outcome = if response.is_ok() { + if let Some(latency_sender) = self.latency_sender.as_ref() { + latency_sender + .send(TxSubmitted { + tx_hash, + submitted_time, + }) + .await + .wrap_err_with(|| { + format!( + "Failed to send tx submission event for tx hash: {}", + tx_hash + ) + })?; + } + ChannelSendOutcome::WaitedAccepted + } else { + ChannelSendOutcome::WaitedRejected + }; + self.result_sender + .send((tx_len, response.map(|_: String| ()))) + .await?; + Ok(outcome) } else { - Ok(tx_len) - }; - - // Only record submission if the node accepted the tx (or we didn't wait) - if result.is_ok() { + // Fire-and-forget: record submission optimistically (the drain + // path will surface real errors against the same request_id). if let Some(latency_sender) = self.latency_sender.as_ref() { latency_sender .send(TxSubmitted { @@ -377,11 +572,12 @@ impl TxSender { ) })?; } + self.result_sender.send((tx_len, Ok(()))).await?; + Ok(ChannelSendOutcome::Fired { + request_id, + node_idx, + }) } - - self.result_sender.send(result).await?; - - Ok(()) } /// Send a transaction and always wait for the JSON-RPC response. @@ -398,12 +594,11 @@ impl TxSender { )); } - let result = self.ws_clients[node_idx] + let response = self.ws_clients[node_idx] .wait_for_response(request_id) - .await - .map(|_: String| tx_len); + .await; - let outcome = match &result { + let outcome = match &response { Ok(_) => { if let Some(latency_sender) = self.latency_sender.as_ref() { latency_sender @@ -432,7 +627,9 @@ impl TxSender { } }; - self.result_sender.send(result).await?; + self.result_sender + .send((tx_len, response.map(|_: String| ()))) + .await?; Ok(outcome) } @@ -443,7 +640,7 @@ impl TxSender { ws_client: &mut WsClient, method: &str, params: serde_json::Value, - result_sender: &mut Sender>, + result_sender: &mut Sender, reconnect_attempts: u32, reconnect_period: Duration, ) -> Result { @@ -488,15 +685,17 @@ impl TxSender { reconnect_err ); - // If this was the last attempt, send error to tracker + // If this was the last attempt, send error to tracker. + // Connection retry failure isn't tied to a single tx, so + // pass 0 bytes; only the offered-count is incremented. if attempt + 1 >= max_attempts { - let result = Err(eyre::eyre!( + let err = eyre::eyre!( "Connection error after {} attempts: {}, last reconnect error: {}", max_attempts, e, reconnect_err - )); - result_sender.send(result).await?; + ); + result_sender.send((0, Err(err))).await?; return Ok(0); // Return dummy ID, error was tracked } @@ -527,11 +726,8 @@ impl TxSender { } // All attempts exhausted - let result = Err(eyre::eyre!( - "Failed to send request after {} attempts", - max_attempts - )); - result_sender.send(result).await?; + let err = eyre::eyre!("Failed to send request after {} attempts", max_attempts); + result_sender.send((0, Err(err))).await?; Ok(0) // Return dummy ID, error was tracked } } diff --git a/crates/spammer/src/spammer.rs b/crates/spammer/src/spammer.rs index 7c3e1357..d9f636d2 100644 --- a/crates/spammer/src/spammer.rs +++ b/crates/spammer/src/spammer.rs @@ -30,7 +30,7 @@ const WS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); const WS_CONNECT_TIMEOUT: Duration = Duration::from_mins(30); use color_eyre::eyre::{self, Result}; -use tokio::sync::mpsc::{self, Sender}; +use tokio::sync::mpsc::{self, Receiver, Sender}; use tokio::time::{self, Duration}; use tracing::{debug, info}; use url::Url; @@ -38,10 +38,10 @@ use url::Url; use alloy_consensus::TxEnvelope; use crate::accounts::AccountBuilder; -use crate::generator::TxGenerator; +use crate::generator::{AckOutcome, TxGenerator}; use crate::latency::{LatencyTracker, TxSubmitted}; use crate::rate_limiter::RateLimiter; -use crate::result_tracker::ResultTracker; +use crate::result_tracker::{ResultTracker, TxOutcome}; use crate::sender::TxSender; use crate::ws::WsClientBuilder; use crate::{Config, ResumeConfig}; @@ -55,8 +55,15 @@ pub const TEST_MNEMONIC: &str = "test test test test test test test test test te const LATENCY_CHANNEL_CAPACITY: usize = 100_000; /// Captured generator state from a completed spammer run. +/// +/// JSON-serialisable so callers can persist it across process restarts +/// (e.g. `quake run saturation` remote mode passes state from one phase's +/// spammer subprocess to the next via a JSON file on CC). Skipped fields +/// in `TxGenerator` (channels, WS clients) are repopulated by +/// [`Spammer::new_resuming`] before the next run starts. +#[derive(serde::Serialize, serde::Deserialize)] pub struct SpammerState { - generators: Vec, + pub(crate) generators: Vec, } /// Result of a completed [`Spammer::run_capturing_state`] run. @@ -67,6 +74,14 @@ pub struct SpammerRunResult { /// fire-and-forget mode from drained server responses. Empty when the /// spammer ran in backpressure mode (which surfaces errors directly). pub rpc_errors: HashMap, + /// Unix millisecond timestamp at which the spammer began the spamming + /// window (after WS connect + nonce warmup completed). Together with + /// `finished_at_unix_ms`, defines the time interval that downstream + /// orchestrators should use to query chain-side metrics so the numerator + /// (Prometheus tx-count delta) aligns with the denominator (elapsed). + pub started_at_unix_ms: i64, + /// Unix millisecond timestamp at which the spammer stopped sending. + pub finished_at_unix_ms: i64, /// Average TPS as observed locally by the spammer: total transactions /// submitted (regardless of server acceptance) divided by wall-clock run /// duration. Distinct from any server-side or chain-confirmed rate — this @@ -80,6 +95,39 @@ pub struct SpammerRunResult { pub actual_offered_bytes_per_sec: f64, } +/// Serializable summary of a spammer run — the JSON-friendly subset of +/// [`SpammerRunResult`] (drops `state`, which holds non-Serialize generator +/// handles). Used by `quake run saturation` to recover phase metrics when the +/// spammer ran on a remote host: the binary writes `summary.json` at the end +/// of the run and the orchestrator reads it back over SCP. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SpammerSummary { + pub started_at_unix_ms: i64, + pub finished_at_unix_ms: i64, + pub actual_offered_tps: f64, + pub actual_offered_bytes_per_sec: f64, + pub rpc_errors: HashMap, +} + +impl SpammerSummary { + /// Seconds the spammer spent actually sending — `(finished - started) / 1000`. + pub fn elapsed_secs(&self) -> f64 { + ((self.finished_at_unix_ms - self.started_at_unix_ms) as f64 / 1000.0).max(0.0) + } +} + +impl From<&SpammerRunResult> for SpammerSummary { + fn from(r: &SpammerRunResult) -> Self { + Self { + started_at_unix_ms: r.started_at_unix_ms, + finished_at_unix_ms: r.finished_at_unix_ms, + actual_offered_tps: r.actual_offered_tps, + actual_offered_bytes_per_sec: r.actual_offered_bytes_per_sec, + rpc_errors: r.rpc_errors.clone(), + } + } +} + impl SpammerState { /// Re-query on-chain pending nonces for all generators in parallel. /// @@ -98,6 +146,30 @@ impl SpammerState { } Ok(()) } + + /// Pre-derive every BIP32 signing key for every generator's account range, + /// in parallel across generators via `tokio::task::spawn_blocking` (the + /// derivation is CPU-bound, not async). Closes the warm-cache gap with + /// local-mode `SpammerState` after deserialising from JSON. No-op when + /// keys are already populated. + pub async fn eagerly_derive_signers(&mut self) -> Result<()> { + // Skip the drain + spawn_blocking + await fan-out for in-process + // resumes where every generator already carries its derived keys. + if self.generators.iter().all(TxGenerator::signers_populated) { + return Ok(()); + } + let mut handles = Vec::with_capacity(self.generators.len()); + for mut tx_gen in self.generators.drain(..) { + handles.push(tokio::task::spawn_blocking(move || { + tx_gen.eagerly_derive_signers()?; + Ok::(tx_gen) + })); + } + for handle in handles { + self.generators.push(handle.await??); + } + Ok(()) + } } /// Transaction load generator orchestrator. @@ -110,6 +182,12 @@ pub struct Spammer { /// Transaction senders that fan out transactions to target nodes in round-robin /// fashion. tx_senders: Vec, + /// Per-generator ack channels: each receiver pairs 1:1 with the + /// generator at the same index and carries `AckOutcome` messages from + /// the matching `TxSender` so the generator can advance or refresh the + /// cached nonce based on the *actual* response from the node. Empty in + /// backpressure mode where the sender owns the generator and acks inline. + tx_ack_receivers: Vec>, /// Tracks transaction results and reports statistics on them. result_tracker: ResultTracker, /// Optional tracker for submit-to-finalized latency measurement. @@ -147,7 +225,7 @@ impl Spammer { ); // Create channels for communication between components - let (result_sender, result_receiver) = mpsc::channel::>(10000); + let (result_sender, result_receiver) = mpsc::channel::(10000); let (finish_sender, finish_receiver) = mpsc::channel::<()>(1); // WS clients to all target Quake endpoints @@ -192,7 +270,7 @@ impl Spammer { )); // Create transaction generators and senders - let (tx_generators, tx_senders) = if config.fire_and_forget { + let (tx_generators, tx_senders, tx_ack_receivers) = if config.fire_and_forget { Self::make_spammers( ws_client_builders.clone(), &result_sender, @@ -202,14 +280,15 @@ impl Spammer { ) .await? } else { - Self::make_loaders( + let (gens, sends) = Self::make_loaders( ws_client_builders.clone(), &result_sender, tx_latency_sender, &rate_limiter, config, ) - .await? + .await?; + (gens, sends, Vec::new()) }; // Create result tracker @@ -225,6 +304,7 @@ impl Spammer { Ok(Self { tx_generators, tx_senders, + tx_ack_receivers, result_tracker, latency_tracker, finish_sender, @@ -238,11 +318,11 @@ impl Spammer { #[allow(clippy::too_many_arguments)] async fn make_spammers( ws_client_builders: Vec, - result_sender: &Sender>, + result_sender: &Sender, tx_latency_sender: Option>, rate_limiter: &Arc, config: &Config, - ) -> Result<(Vec, Vec)> { + ) -> Result<(Vec, Vec, Vec>)> { // Partition account space among generators let ranges = config .partition_mode @@ -257,8 +337,9 @@ impl Spammer { let mut tx_generators = Vec::new(); let mut tx_senders = Vec::new(); + let mut ack_receivers = Vec::new(); for (i, (start, end)) in ranges.into_iter().enumerate() { - let (tx_gen, sender) = Self::make_spammer( + let (tx_gen, sender, ack_rx) = Self::make_spammer( i, start..end, &account_builder, @@ -272,9 +353,10 @@ impl Spammer { tx_generators.push(tx_gen); tx_senders.push(sender); + ack_receivers.push(ack_rx); } - Ok((tx_generators, tx_senders)) + Ok((tx_generators, tx_senders, ack_receivers)) } /// Create a single tx generator and sender for a given range of accounts. @@ -284,13 +366,19 @@ impl Spammer { range: Range, account_builder: &AccountBuilder, ws_client_builders: Vec, - result_sender: &Sender>, + result_sender: &Sender, tx_latency_sender: Option>, rate_limiter: &Arc, config: &Config, - ) -> Result<(TxGenerator, TxSender)> { + ) -> Result<(TxGenerator, TxSender, Receiver)> { // Buffered channel to send transactions from generator to sender - let (tx_sender, tx_receiver) = mpsc::channel::(10000); + let (tx_sender, tx_receiver) = mpsc::channel::<(TxEnvelope, usize)>(10000); + // Buffered ack channel sender→generator. Sized to match the tx channel + // so backpressure on tx submission is mirrored on the ack path; if the + // generator stalls processing acks the sender will eventually block on + // emit_ack, which is the same shape of backpressure we already accept + // on the tx side. + let (ack_sender, ack_receiver) = mpsc::channel::(10000); debug!("TxGenerator {i}: creating with signers in range {range:?}..."); let mut tx_gen = TxGenerator::new( @@ -326,6 +414,7 @@ impl Spammer { ws_client_builders.to_owned(), tx_receiver, result_sender.clone(), + ack_sender, rate_limiter.clone(), crate::sender::TxSenderConfig { max_time: config.max_time, @@ -337,14 +426,14 @@ impl Spammer { ) .await?; - Ok((tx_gen, sender)) + Ok((tx_gen, sender, ack_receiver)) } /// Create senders in backpressure mode: each sender owns its generator directly. #[allow(clippy::too_many_arguments)] async fn make_loaders( ws_client_builders: Vec, - result_sender: &Sender>, + result_sender: &Sender, tx_latency_sender: Option>, rate_limiter: &Arc, config: &Config, @@ -425,9 +514,19 @@ impl Spammer { let mut tx_gen_handles: Vec>> = Vec::new(); if !self.tx_generators.is_empty() { + // The ack receiver list is built 1:1 with `tx_generators` in + // `make_spammers` / `new_resuming` so popping in order pairs each + // generator with its sender's outcome channel. Backpressure mode + // leaves the list empty — generator runs inside the sender and + // acks inline, so we never index past the available receivers. + let mut ack_receivers: std::collections::VecDeque> = + self.tx_ack_receivers.into_iter().collect(); for mut tx_gen in self.tx_generators { + let ack_rx = ack_receivers.pop_front().ok_or_else(|| { + eyre::eyre!("tx_ack_receivers must match tx_generators count") + })?; tx_gen_handles.push(tokio::spawn(async move { - tx_gen.run().await?; + tx_gen.run(ack_rx).await?; Ok(tx_gen) })); } @@ -442,11 +541,19 @@ impl Spammer { tx_sender_handles.push(tokio::spawn(async move { tx_sender.run().await })); } + let started_at_unix_ms = chrono::Utc::now().timestamp_millis(); + let load_start = std::time::Instant::now(); let tracker_handle = tokio::spawn(async move { self.result_tracker.run().await }); + info!("Load STARTED — tx_senders + tracker now running"); for handle in tx_sender_handles { handle.await??; } + let finished_at_unix_ms = chrono::Utc::now().timestamp_millis(); + info!( + "Load STOPPED — load window was {:.1}s", + load_start.elapsed().as_secs_f64(), + ); let mut generators = Vec::new(); for handle in tx_gen_handles { @@ -467,6 +574,8 @@ impl Spammer { Ok(SpammerRunResult { state: SpammerState { generators }, rpc_errors: summary.errors, + started_at_unix_ms, + finished_at_unix_ms, actual_offered_tps, actual_offered_bytes_per_sec, }) @@ -481,6 +590,30 @@ impl Spammer { if target_ws_urls.is_empty() { eyre::bail!("No target nodes provided"); } + // An empty state would silently produce a no-op run with zero + // generators/senders. This happens when a previous backpressure-mode + // run wrote --state-out: its generators are owned by senders and + // never surface in SpammerState. Fail fast so the caller fixes the + // upstream config rather than chasing a phantom "successful" phase. + if state.generators.is_empty() { + eyre::bail!( + "SpammerState has zero generators; refusing to resume a no-op run \ + (was --state-out produced by a backpressure-mode run?)" + ); + } + + let resume_started = std::time::Instant::now(); + info!( + "Resume start: {} generators, targets {}, max_rate={}, max_time={}s", + state.generators.len(), + target_ws_urls + .iter() + .map(|(node, _)| node.as_str()) + .collect::>() + .join(","), + config.max_rate, + config.max_time, + ); let mut ws_client_builders = Vec::new(); for (_, url) in &target_ws_urls { @@ -491,19 +624,42 @@ impl Spammer { } for tx_gen in &mut state.generators { tx_gen.update_ws_client_builders(ws_client_builders.clone()); + // After deserialising from a JSON state file, `signers` is empty + // (skipped by serde). Size it so the lazy per-account derivation + // in next_tx has indexable slots. No-op for in-process resumes + // where signers are still populated. + tx_gen.ensure_signers_capacity(); } + // Pre-derive every signing key in parallel across generators so the + // first cycle through accounts in the next phase doesn't pay BIP32 + // cost inside `next_tx`. Closes the warm-cache gap that local mode + // has for free (signers persisted in-process). + let derive_started = std::time::Instant::now(); + info!("Deriving signing keys (BIP32)..."); + state.eagerly_derive_signers().await?; + info!( + "Deriving signing keys: done in {:.1}s", + derive_started.elapsed().as_secs_f64() + ); + let resync_started = std::time::Instant::now(); + info!("Resyncing nonces against chain..."); state.resync_nonces().await?; + info!( + "Resyncing nonces: done in {:.1}s", + resync_started.elapsed().as_secs_f64() + ); let num_generators = state.generators.len(); info!( - "Resuming spam generator for {} nodes, {} generators at {} TPS for {}s", - target_ws_urls.len(), + "Resume setup complete in {:.1}s. About to wire {} generators + senders + tracker for {} nodes at {} TPS for {}s.", + resume_started.elapsed().as_secs_f64(), num_generators, + target_ws_urls.len(), config.max_rate, config.max_time, ); - let (result_sender, result_receiver) = mpsc::channel::>(10000); + let (result_sender, result_receiver) = mpsc::channel::(10000); let (finish_sender, finish_receiver) = mpsc::channel::<()>(1); let (tx_latency_sender, latency_tracker) = if config.tx_latency { @@ -537,8 +693,11 @@ impl Spammer { let mut tx_generators = Vec::new(); let mut tx_senders = Vec::new(); + let mut tx_ack_receivers = Vec::new(); for (i, mut tx_gen) in state.generators.into_iter().enumerate() { - let (tx_channel_sender, tx_channel_receiver) = mpsc::channel::(10000); + let (tx_channel_sender, tx_channel_receiver) = + mpsc::channel::<(TxEnvelope, usize)>(10000); + let (ack_sender, ack_receiver) = mpsc::channel::(10000); tx_gen.reset_tx_sender(tx_channel_sender); let sender = TxSender::new_channel( @@ -546,6 +705,7 @@ impl Spammer { ws_client_builders.clone(), tx_channel_receiver, result_sender.clone(), + ack_sender, rate_limiter.clone(), crate::sender::TxSenderConfig { max_time: config.max_time, @@ -559,6 +719,7 @@ impl Spammer { tx_generators.push(tx_gen); tx_senders.push(sender); + tx_ack_receivers.push(ack_receiver); } let result_tracker = ResultTracker::new( @@ -573,6 +734,7 @@ impl Spammer { Ok(Self { tx_generators, tx_senders, + tx_ack_receivers, result_tracker, latency_tracker, finish_sender, @@ -584,3 +746,45 @@ impl Spammer { self.run_capturing_state().await.map(|_| ()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_resume_config() -> ResumeConfig { + ResumeConfig { + max_rate: 1, + max_num_txs: 0, + max_time: 0, + wait_response: false, + reconnect_attempts: 0, + reconnect_period: Duration::from_secs(1), + silent: true, + show_pool_status: false, + tx_latency: false, + csv_dir: None, + } + } + + #[tokio::test] + async fn new_resuming_rejects_empty_state() { + let target_ws_urls = vec![( + "dummy".to_string(), + Url::parse("ws://127.0.0.1:1").expect("parse ws url"), + )]; + let state = SpammerState { + generators: Vec::new(), + }; + let config = dummy_resume_config(); + + let err = match Spammer::new_resuming(target_ws_urls, state, &config).await { + Ok(_) => panic!("empty state must not resume"), + Err(e) => e, + }; + let msg = format!("{err}"); + assert!( + msg.contains("zero generators"), + "unexpected error message: {msg}" + ); + } +} diff --git a/crates/spammer/src/ws.rs b/crates/spammer/src/ws.rs index a3035009..50366707 100644 --- a/crates/spammer/src/ws.rs +++ b/crates/spammer/src/ws.rs @@ -230,30 +230,40 @@ impl WsClient { Err(eyre::eyre!("timeout waiting for notification")) } - /// Non-blockingly drain one pending inbound message and return its error, - /// if any. + /// Non-blockingly drain one pending inbound message and return its + /// `(request_id, outcome)` pair. /// - /// Successful responses return `None` — fire-and-forget mode already counts - /// them at submit time, so surfacing them here would double-count. - /// Server-reported errors return `Some(Err(...))` so the sender can forward - /// them to the result tracker. Pings are answered with a pong; close frames - /// surface as a connection error so the sender's reconnect path engages. - pub(crate) async fn drain_one_result(&mut self) -> Option> { + /// Returns `Some((request_id, Ok(())))` for a successful response so the + /// sender can pair the response with its in-flight account and ack the + /// nonce — fire-and-forget callers that *only* care about errors should + /// just discard the success arm. Returns `Some((request_id, Err(...)))` + /// for server-reported errors. Pings are answered with a pong; close + /// frames surface as a connection error against `request_id = 0` so the + /// sender's reconnect path engages without spuriously acking an unrelated + /// account. + pub(crate) async fn drain_one_result(&mut self) -> Option<(u64, Result<()>)> { tokio::select! { biased; msg = self.ws.next() => { let msg = msg?.ok()?; match classify_ws_message(msg) { - Ok(WsMessageAction::Response(body)) => body - .error - .map(|JsonError { code, message }| { - Err(eyre::eyre!("Server Error {}: {}", code, message)) - }), + Ok(WsMessageAction::Response(body)) => { + let request_id = body.id.as_u64().unwrap_or(0); + match body.error { + Some(JsonError { code, message }) => Some(( + request_id, + Err(eyre::eyre!("Server Error {}: {}", code, message)), + )), + None => Some((request_id, Ok(()))), + } + } Ok(WsMessageAction::Ping(p)) => { let _ = self.ws.send(Message::Pong(p.into())).await; None } - Ok(WsMessageAction::Closed) => Some(Err(WsError::ConnectionClosed.into())), + Ok(WsMessageAction::Closed) => { + Some((0, Err(WsError::ConnectionClosed.into()))) + } _ => None, } } diff --git a/crates/test/checks/src/mev.rs b/crates/test/checks/src/mev.rs index 618c7188..31e2a823 100644 --- a/crates/test/checks/src/mev.rs +++ b/crates/test/checks/src/mev.rs @@ -24,6 +24,7 @@ //! - **Pending-block suppression** — `eth_getBlockByNumber("pending")` returns null //! - **Pending state fallback** — state methods with `"pending"` tag match `"latest"` //! - **Pending-tx RPCs blocked** — `eth_newPendingTransactionFilter` returns `-32001` +//! - **Bundle RPCs unavailable** — all six Reth bundle methods return method-not-found //! - **Sensitive namespaces disabled** — txpool, debug, trace, admin, flashbots, mev, ots //! - **Mempool nonce lookup blocked** — `eth_getTransactionBySenderAndNonce` returns no data @@ -38,6 +39,8 @@ use crate::types::{CheckResult, Report}; /// JSON-RPC error code returned by Arc's pending-tx filter. const ARC_BLOCKED_ERROR_CODE: i64 = -32001; +/// Standard JSON-RPC error code for a method that is not registered. +const METHOD_NOT_FOUND_ERROR_CODE: i64 = -32601; /// Default address for state-query assertions (Anvil account #0, localdev genesis). pub const DEFAULT_ADDR: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; @@ -273,6 +276,39 @@ async fn check_namespace_disabled( } } +async fn check_method_not_found( + client: &reqwest::Client, + url: &Url, + node: &str, + method: &str, + params: Value, +) -> CheckResult { + match rpc_call(client, url, method, params).await { + RpcOutcome::Err { code, .. } if code == METHOD_NOT_FOUND_ERROR_CODE => CheckResult { + name: node.into(), + passed: true, + message: format!("{method} is not exposed"), + }, + RpcOutcome::Ok(_) => CheckResult { + name: node.into(), + passed: false, + message: format!("{method} returned data; bundle RPC must not be exposed"), + }, + RpcOutcome::Err { code, message } => CheckResult { + name: node.into(), + passed: false, + message: format!( + "{method} unexpected error {code}: {message} (expected method-not-found)" + ), + }, + RpcOutcome::Transport(e) => CheckResult { + name: node.into(), + passed: false, + message: format!("{method} transport error: {e}"), + }, + } +} + async fn check_mempool_nonce_blocked( client: &reqwest::Client, url: &Url, @@ -414,7 +450,19 @@ async fn check_node( .await, ); - // 9. Sensitive namespaces disabled + // 9. Bundle RPC methods are never exposed, independently of namespace selection + for method in [ + "eth_callBundle", + "eth_sendBundle", + "eth_cancelBundle", + "eth_sendPrivateTransaction", + "eth_sendPrivateRawTransaction", + "eth_cancelPrivateTransaction", + ] { + checks.push(check_method_not_found(client, url, node, method, json!([])).await); + } + + // 10. Sensitive namespaces disabled for method in &["txpool_status", "txpool_inspect", "txpool_content"] { checks.push(check_namespace_disabled(client, url, node, method, json!([])).await); } @@ -479,7 +527,7 @@ async fn check_node( .await, ); - // 10. Mempool nonce lookup blocked + // 11. Mempool nonce lookup blocked checks.push(check_mempool_nonce_blocked(client, url, node, addr).await); checks diff --git a/crates/test/checks/src/perf.rs b/crates/test/checks/src/perf.rs index 8d2a18b6..3242f938 100644 --- a/crates/test/checks/src/perf.rs +++ b/crates/test/checks/src/perf.rs @@ -124,8 +124,7 @@ fn estimate_percentile(buckets: &[(f64, f64)], total_count: f64, quantile: f64) buckets .iter() - .filter(|(le, _)| !le.is_infinite()) - .next_back() + .rfind(|(le, _)| !le.is_infinite()) .map(|(le, _)| *le) .unwrap_or(0.0) } diff --git a/crates/test/framework/src/lib.rs b/crates/test/framework/src/lib.rs index 836998ab..7fc663a0 100644 --- a/crates/test/framework/src/lib.rs +++ b/crates/test/framework/src/lib.rs @@ -337,12 +337,16 @@ async fn run_test( let runner = runner.clone(); let node_id = node.id; join_set.spawn(async move { - let result = tokio::time::timeout(timeout, run_node(runner, node)).await; - match result { - Ok(outcome) => outcome, + match tokio::time::timeout(timeout, run_node(runner, node)).await { + Ok(result) => result, Err(_) => { + // The timed-out `run_node` future is dropped here, which + // drops its handle and shuts the node down; report no handle. error!(%node_id, "Node timed out after {timeout:?}"); - node_failure(node_id, format!("timed out after {timeout:?}")) + ( + node_failure(node_id, format!("timed out after {timeout:?}")), + None, + ) } } }); @@ -352,10 +356,17 @@ async fn run_test( } /// Run a single node through its step sequence. +/// +/// Returns the node's outcome together with its live handle (when one was +/// spawned). The caller keeps the handle alive until the whole test finishes so +/// that nodes which complete their steps early — e.g. background support +/// validators — keep running to support any peer still recovering. The handle +/// is `None` only when the node was never spawned (spawn failed) or its handle +/// was consumed by a failed restart. async fn run_node( runner: R, node: TestNode, -) -> NodeOutcome { +) -> (NodeOutcome, Option) { let node_id = node.id; if !node.start_delay.is_zero() { @@ -367,7 +378,7 @@ async fn run_node( Ok(h) => h, Err(e) => { error!(%node_id, error = ?e, "Failed to spawn node"); - return node_failure(node_id, format!("spawn failed: {e:#}")); + return (node_failure(node_id, format!("spawn failed: {e:#}")), None); } }; @@ -382,7 +393,7 @@ async fn run_node( Step::WaitUntilBlock(target_height) => loop { let event = match recv_or_fail(&mut rx, node_id, "waiting for block").await { Ok(event) => event, - Err(outcome) => return outcome, + Err(outcome) => return (outcome, Some(handle)), }; match event { ArcEvent::BlockProduced { number, .. } if number >= target_height => { @@ -403,7 +414,7 @@ async fn run_node( Step::WaitUntilDecision(target_height) => loop { let event = match recv_or_fail(&mut rx, node_id, "waiting for decision").await { Ok(event) => event, - Err(outcome) => return outcome, + Err(outcome) => return (outcome, Some(handle)), }; if let ArcEvent::ConsensusDecided { height, .. } = event { decisions += 1; @@ -417,7 +428,7 @@ async fn run_node( Step::OnEvent(handler) => loop { let event = match recv_or_fail(&mut rx, node_id, "in OnEvent handler").await { Ok(event) => event, - Err(outcome) => return outcome, + Err(outcome) => return (outcome, Some(handle)), }; if matches!(event, ArcEvent::ConsensusDecided { .. }) { decisions += 1; @@ -430,7 +441,10 @@ async fn run_node( break; } Err(e) => { - return node_failure(node_id, format!("event handler error: {e}")); + return ( + node_failure(node_id, format!("event handler error: {e}")), + Some(handle), + ); } } }, @@ -440,7 +454,10 @@ async fn run_node( tokio::time::sleep(delay).await; } if let Err(e) = kill_layer(&handle, layer).await { - return node_failure(node_id, format!("crash/kill failed: {e}")); + return ( + node_failure(node_id, format!("crash/kill failed: {e}")), + Some(handle), + ); } info!(%node_id, "Node crashed"); } @@ -452,7 +469,11 @@ async fn run_node( handle = match runner.restart(node_id, handle, layer).await { Ok(h) => h, Err(e) => { - return node_failure(node_id, format!("restart failed: {e:#}")); + // `handle` was moved into `restart` and dropped on failure. + return ( + node_failure(node_id, format!("restart failed: {e:#}")), + None, + ); } }; rx = handle.subscribe(); @@ -461,51 +482,68 @@ async fn run_node( Step::Expect(expected, layer) => { if let Err(e) = kill_layer(&handle, layer).await { - return node_failure( - node_id, - format!("failed to kill node before expect: {e}"), + return ( + node_failure(node_id, format!("failed to kill node before expect: {e}")), + Some(handle), ); } if expected.check(decisions) { info!(%node_id, decisions, %expected, "Expectation met"); info!(%node_id, "Node test passed"); - return NodeOutcome::Success; + return (NodeOutcome::Success, Some(handle)); } else { - return node_failure( - node_id, - format!("expected {expected} decisions, got {decisions}"), + return ( + node_failure( + node_id, + format!("expected {expected} decisions, got {decisions}"), + ), + Some(handle), ); } } Step::Success => { info!(%node_id, "Node test passed"); - return NodeOutcome::Success; + return (NodeOutcome::Success, Some(handle)); } Step::Fail(reason) => { - return node_failure(node_id, reason); + return (node_failure(node_id, reason), Some(handle)); } } } - node_failure( - node_id, - "step sequence ended without an explicit terminal step (use .success(), .fail(...), or .expect_decisions(...))" - .to_string(), + ( + node_failure( + node_id, + "step sequence ended without an explicit terminal step (use .success(), .fail(...), or .expect_decisions(...))" + .to_string(), + ), + Some(handle), ) } /// Collect results from all node tasks and fail if any node failed. -async fn check_results(join_set: &mut JoinSet) { +/// +/// Node handles are kept alive until *every* task has finished. A node that +/// completes its step sequence early (e.g. a background support validator) must +/// keep running — serving sync requests and consensus votes — so a peer still +/// recovering (e.g. after a crash/restart) can catch up. Dropping a handle +/// shuts the node down, so handles are released only once all tasks complete. +async fn check_results(join_set: &mut JoinSet<(NodeOutcome, Option)>) { let mut failures = Vec::new(); + let mut live_handles = Vec::new(); while let Some(result) = join_set.join_next().await { match result { - Ok(NodeOutcome::Success) => {} - Ok(NodeOutcome::Failed(reason)) => { - error!(%reason, "Node failed"); - failures.push(reason); + Ok((outcome, handle)) => { + if let Some(handle) = handle { + live_handles.push(handle); + } + if let NodeOutcome::Failed(reason) = outcome { + error!(%reason, "Node failed"); + failures.push(reason); + } } Err(e) => { let msg = if e.is_panic() { @@ -525,6 +563,9 @@ async fn check_results(join_set: &mut JoinSet) { } } + // Every task has finished; tearing the nodes down is now safe. + drop(live_handles); + if !failures.is_empty() { panic!( "Test failed with {} failure(s):\n{}", diff --git a/crates/test/integration/src/runner.rs b/crates/test/integration/src/runner.rs index 97cf00de..d5d3d4a4 100644 --- a/crates/test/integration/src/runner.rs +++ b/crates/test/integration/src/runner.rs @@ -73,8 +73,10 @@ use tracing::{error, info}; use arc_consensus_types::{ArcContext, Config as ConsensusConfig}; use arc_evm_node::node::{ArcNode, ArcRpcConfig}; -use arc_evm_node::ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT; -use arc_execution_config::addresses_denylist::AddressesDenylistConfig; +use arc_evm_node::{ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, DEFAULT_TX_RELAY_TIMEOUT}; +use arc_execution_config::addresses_denylist::{ + AddressesDenylistConfig, DEFAULT_DENYLIST_ERC7201_BASE_SLOT, DENYLIST_ADDRESS_LOCALDEV, +}; use arc_execution_config::chainspec::{localdev_with_block_gas_limit, ArcChainSpec, LOCAL_DEV}; use arc_execution_txpool::InvalidTxListConfig; use arc_node_consensus::hardcoded_config::{ @@ -607,6 +609,9 @@ fn build_node_consensus_config( rpc: arc_consensus_types::RpcConfig { enabled: true, listen_addr: rpc_listen_addr, + // In-process integration nodes expose the full API, including the + // admin routes. + admin: true, }, ..Default::default() }; @@ -712,7 +717,11 @@ async fn spawn_execution_layer( let arc_node = ArcNode::new( ArcRpcConfig::default(), InvalidTxListConfig::default(), - AddressesDenylistConfig::default(), + AddressesDenylistConfig::new( + DENYLIST_ADDRESS_LOCALDEV, + DEFAULT_DENYLIST_ERC7201_BASE_SLOT, + Vec::new(), + ), None, true, false, @@ -720,6 +729,8 @@ async fn spawn_execution_layer( 160 * 1024 * 1024, ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT, std::time::Duration::from_secs(0), + Vec::new(), + DEFAULT_TX_RELAY_TIMEOUT, ); let reth_handle = NodeBuilder::new(node_config) diff --git a/crates/test/integration/tests/basic.rs b/crates/test/integration/tests/basic.rs index e0f17583..256c1020 100644 --- a/crates/test/integration/tests/basic.rs +++ b/crates/test/integration/tests/basic.rs @@ -50,6 +50,7 @@ async fn validators_reach_height_3() { .await; } +// Extra headroom: this scenario also pays for a crash, restart delay, and resync. #[rstest] #[case::both(Layer::Both)] #[case::cl(Layer::Consensus)] @@ -57,7 +58,7 @@ async fn validators_reach_height_3() { #[tokio::test] async fn crash_and_restart(#[case] layer: Layer) { scenarios::crash_and_restart(5, 3, Duration::from_secs(1), 10, layer) - .run::(Duration::from_secs(60)) + .run::(Duration::from_secs(90)) .await; } diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 0228926b..d3b92e62 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -10,12 +10,14 @@ publish.workspace = true [features] default = ["signer-local"] arbitrary = ["dep:arbitrary"] +byzantine = ["dep:malachitebft-engine-byzantine"] signer-local = ["dep:malachitebft-signing-ed25519"] [dependencies] # alloy alloy-consensus = { workspace = true } +alloy-eips = { workspace = true } alloy-primitives = { workspace = true, features = ["serde"], default-features = false } alloy-rlp = { workspace = true } alloy-rpc-types-engine = { workspace = true, features = ["ssz"] } @@ -38,6 +40,7 @@ malachitebft-app-channel = { workspace = true } malachitebft-codec = { workspace = true } malachitebft-core-consensus = { workspace = true } malachitebft-core-types = { workspace = true, features = ["serde"] } +malachitebft-engine-byzantine = { workspace = true, optional = true } malachitebft-proto = { workspace = true } malachitebft-signing = { workspace = true } malachitebft-signing-ed25519 = { workspace = true, optional = true, features = ["rand", "serde"] } diff --git a/crates/types/proto/arc/sync/v1/sync.proto b/crates/types/proto/arc/sync/v1/sync.proto index 367e027e..c390ab67 100644 --- a/crates/types/proto/arc/sync/v1/sync.proto +++ b/crates/types/proto/arc/sync/v1/sync.proto @@ -49,6 +49,7 @@ message CommitSignature { // TODO: Add flag (no vote, nil, value?) arc.consensus.v1.Address validator_address = 1; arc.consensus.v1.Signature signature = 2; + optional arc.consensus.v1.Extension extension = 3; } message CommitCertificate { diff --git a/crates/types/src/block.rs b/crates/types/src/block.rs index 8f82e208..7abcde05 100644 --- a/crates/types/src/block.rs +++ b/crates/types/src/block.rs @@ -14,7 +14,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -use alloy_rpc_types_engine::ExecutionPayloadV3; +use alloy_eips::eip7685::Requests; +use alloy_rpc_types_engine::{ + CancunPayloadFields, ExecutionData, ExecutionPayload, ExecutionPayloadSidecar, + ExecutionPayloadV3, PayloadError, PraguePayloadFields, +}; use bytesize::ByteSize; use ssz::Encode; @@ -43,14 +47,36 @@ pub struct ConsensusBlock { } impl ConsensusBlock { - /// Returns the block hash of the execution payload. - pub fn block_hash(&self) -> BlockHash { + /// Returns the block hash carried verbatim in the execution payload. + /// + /// Not recomputed from the payload, so it is untrusted; use + /// [`Self::canonical_block_hash`] where a verified hash is required. + pub fn self_reported_block_hash(&self) -> BlockHash { self.execution_payload .payload_inner .payload_inner .block_hash } + /// Recomputes the canonical block hash from the execution payload contents. + pub fn canonical_block_hash(&self) -> Result { + canonical_block_hash(&self.execution_payload) + } + + /// Returns whether the self-reported block hash matches the hash recomputed + /// from the payload contents. + pub fn self_reported_hash_is_canonical(&self) -> bool { + self.canonical_block_hash() + .is_ok_and(|canonical| canonical == self.self_reported_block_hash()) + } + + /// Returns whether this block may be keyed into the undecided-blocks table: + /// valid blocks always may; invalid blocks only if their self-reported hash + /// is canonical. + pub fn may_be_stored_as_undecided(&self) -> bool { + self.validity.is_valid() || self.self_reported_hash_is_canonical() + } + /// Returns the size of the block in bytes when encoded using SSZ. pub fn size_bytes(&self) -> ByteSize { // TODO: Cache this @@ -61,18 +87,29 @@ impl ConsensusBlock { pub fn payload_size(&self) -> ByteSize { ByteSize::b(self.execution_payload.ssz_bytes_len() as u64) } + + /// Builds the [`ProposedValue`] voted on for this block, using `validity` for + /// the vote instead of the block's persisted [`Self::validity`]. + /// + /// The value id is always the self-reported (wire) hash peers vote on; only + /// the vote validity is overridden, so a caller can prevote nil on a block it + /// still stores with a different (execution-only) validity. + pub fn to_proposed_value_with_validity(&self, validity: Validity) -> ProposedValue { + ProposedValue { + height: self.height, + round: self.round, + proposer: self.proposer, + valid_round: self.valid_round, + value: Value::new(self.self_reported_block_hash()), + validity, + } + } } +// The value id is the self-reported (wire) hash that peers vote on. impl From<&ConsensusBlock> for ProposedValue { fn from(block: &ConsensusBlock) -> Self { - ProposedValue { - height: block.height, - round: block.round, - proposer: block.proposer, - valid_round: block.valid_round, - value: Value::new(block.block_hash()), - validity: block.validity, - } + block.to_proposed_value_with_validity(block.validity) } } @@ -81,11 +118,32 @@ impl From<&ConsensusBlock> for LocallyProposedValue { LocallyProposedValue { height: block.height, round: block.round, - value: Value::new(block.block_hash()), + value: Value::new(block.self_reported_block_hash()), } } } +/// Recomputes the canonical block hash of an execution payload from its +/// contents. +/// +/// Arc has no beacon chain and no execution-layer requests: the +/// `parent_beacon_block_root` is the parent block hash, and the Prague requests +/// list is always empty. The block is reconstructed through the same +/// sidecar-aware path the execution layer uses, so the hash always matches the +/// one the engine validates in `engine_newPayloadV3`. +/// +/// Clones the payload to reconstruct the block; keep it off the hot path. +pub fn canonical_block_hash(payload: &ExecutionPayloadV3) -> Result { + let parent_beacon_block_root = payload.payload_inner.payload_inner.parent_hash; + let sidecar = ExecutionPayloadSidecar::v4( + CancunPayloadFields::new(parent_beacon_block_root, vec![]), + PraguePayloadFields::new(Requests::default()), + ); + let block = + ExecutionData::new(ExecutionPayload::V3(payload.clone()), sidecar).into_block_raw()?; + Ok(block.header.hash_slow()) +} + /// Converts a ConsensusBlock into a tuple suitable for SSZ encoding pub fn block_as_ssz_data(block: &ConsensusBlock) -> SszBlock<&'_ ExecutionPayloadV3> { ( @@ -134,3 +192,117 @@ impl DecidedBlock { self.certificate.height } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Block, B256}; + + fn block_with_self_reported_hash(self_reported: BlockHash) -> ConsensusBlock { + let inner: Block = Block::default(); + let mut payload = ExecutionPayloadV3::from_block_unchecked(self_reported, &inner); + payload.payload_inner.payload_inner.block_hash = self_reported; + + ConsensusBlock { + height: Height::new(1), + round: Round::new(0), + valid_round: Round::Nil, + proposer: Address::new([0u8; 20]), + validity: Validity::Valid, + execution_payload: payload, + signature: None, + } + } + + #[test] + fn canonical_block_hash_is_independent_of_self_reported_hash() { + let canonical = block_with_self_reported_hash(B256::ZERO) + .canonical_block_hash() + .expect("recompute canonical hash"); + + // The canonical hash is derived from the payload contents, so it is the + // same regardless of which value the payload claims as its block hash. + let other = block_with_self_reported_hash(B256::repeat_byte(0xAB)) + .canonical_block_hash() + .expect("recompute canonical hash"); + + assert_eq!(canonical, other); + } + + #[test] + fn self_reported_hash_is_canonical_only_when_it_matches_contents() { + let canonical = block_with_self_reported_hash(B256::ZERO) + .canonical_block_hash() + .expect("recompute canonical hash"); + + let matching = block_with_self_reported_hash(canonical); + assert_eq!(matching.self_reported_block_hash(), canonical); + assert!(matching.self_reported_hash_is_canonical()); + + let mismatched = block_with_self_reported_hash(B256::repeat_byte(0x99)); + assert!(!mismatched.self_reported_hash_is_canonical()); + assert_eq!( + mismatched.canonical_block_hash().expect("recompute"), + canonical + ); + } + + #[test] + fn may_be_stored_as_undecided_rejects_only_invalid_non_canonical_blocks() { + let canonical = block_with_self_reported_hash(B256::ZERO) + .canonical_block_hash() + .expect("recompute canonical hash"); + + let mut valid_non_canonical = block_with_self_reported_hash(B256::repeat_byte(0x99)); + valid_non_canonical.validity = Validity::Valid; + assert!(valid_non_canonical.may_be_stored_as_undecided()); + + let mut invalid_canonical = block_with_self_reported_hash(canonical); + invalid_canonical.validity = Validity::Invalid; + assert!(invalid_canonical.may_be_stored_as_undecided()); + + let mut invalid_non_canonical = block_with_self_reported_hash(B256::repeat_byte(0x99)); + invalid_non_canonical.validity = Validity::Invalid; + assert!(!invalid_non_canonical.may_be_stored_as_undecided()); + } + + #[test] + fn to_proposed_value_with_validity_overrides_only_the_vote_validity() { + let mut block = block_with_self_reported_hash(B256::repeat_byte(0x7)); + block.validity = Validity::Valid; + + let voted = block.to_proposed_value_with_validity(Validity::Invalid); + + // The vote validity is overridden, while the block's persisted validity + // is untouched and the value id + metadata still match the block. + assert_eq!(voted.validity, Validity::Invalid); + assert_eq!(block.validity, Validity::Valid); + assert_eq!(voted.value, Value::new(block.self_reported_block_hash())); + assert_eq!(voted.height, block.height); + assert_eq!(voted.round, block.round); + assert_eq!(voted.proposer, block.proposer); + assert_eq!(voted.valid_round, block.valid_round); + + // The `From` impl keeps deriving the vote validity from the block. + assert_eq!(ProposedValue::from(&block).validity, Validity::Valid); + } + + #[test] + fn canonical_block_hash_includes_prague_requests_hash() { + // Arc runs Prague with no execution requests, so the execution layer + // seals headers with the empty-requests hash and the parent hash as the + // beacon root. The canonical hash must match that reconstruction, not + // the requests_hash = None one that `into_block_raw` produces on its own. + let payload = block_with_self_reported_hash(B256::ZERO).execution_payload; + + let mut expected = payload.clone().into_block_raw().expect("into_block_raw"); + expected.header.parent_beacon_block_root = + Some(payload.payload_inner.payload_inner.parent_hash); + expected.header.requests_hash = Some(alloy_eips::eip7685::EMPTY_REQUESTS_HASH); + + assert_eq!( + canonical_block_hash(&payload).expect("canonical hash"), + expected.header.hash_slow(), + ); + } +} diff --git a/crates/types/src/codec/mod.rs b/crates/types/src/codec/mod.rs index 2489bdf6..6487896c 100644 --- a/crates/types/src/codec/mod.rs +++ b/crates/types/src/codec/mod.rs @@ -38,7 +38,56 @@ macro_rules! impl_versioned_codec { return Err($crate::codec::error::CodecError::EmptyBytes); } - // TODO: Phase 3: Remove after all nodes are upgraded to use versioning + let version_byte = bytes.get_u8(); + let version = <$version_ty>::try_from(version_byte) + .map_err($crate::codec::error::CodecError::UnsupportedVersion)?; + if version != $version_val { + return Err($crate::codec::error::CodecError::UnsupportedVersion( + version_byte, + )); + } + + malachitebft_codec::Codec::decode(&$crate::codec::proto::ProtobufCodec, bytes) + .map_err($crate::codec::error::CodecError::Protobuf) + } + + fn encode(&self, msg: &$ty) -> Result { + use bytes::BufMut; + + let encoded = + malachitebft_codec::Codec::encode(&$crate::codec::proto::ProtobufCodec, msg) + .map_err($crate::codec::error::CodecError::Protobuf)?; + + #[allow(clippy::arithmetic_side_effects)] // 1 + valid allocation length + let mut result = bytes::BytesMut::with_capacity(1 + encoded.len()); + result.put_u8($version_val as u8); + result.put(encoded); + + Ok(result.freeze()) + } + } + }; +} + +/// Shared macro for implementing versioned codecs that can still read legacy +/// unversioned protobuf bytes. +/// +/// This is only for persisted data formats that predate version bytes, such as +/// the WAL. Networked types are already versioned and should use +/// `impl_versioned_codec!` instead. +macro_rules! impl_versioned_codec_with_legacy_fallback { + ($codec_ty:ty, $ty:ty, $version_ty:ty, $version_val:expr) => { + impl malachitebft_codec::Codec<$ty> for $codec_ty { + type Error = $crate::codec::error::CodecError; + + fn decode(&self, mut bytes: bytes::Bytes) -> Result<$ty, Self::Error> { + use bytes::Buf; + + if bytes.is_empty() { + return Err($crate::codec::error::CodecError::EmptyBytes); + } + + // TODO: Phase 3: Remove after all persisted WAL entries use versioning. if let Ok(msg) = malachitebft_codec::Codec::decode( &$crate::codec::proto::ProtobufCodec, bytes.clone(), @@ -77,8 +126,6 @@ macro_rules! impl_versioned_codec { }; } -pub(crate) use impl_versioned_codec; - pub mod error; pub mod network; pub mod proto; diff --git a/crates/types/src/codec/network.rs b/crates/types/src/codec/network.rs index 9b7b3d45..5b2e3138 100644 --- a/crates/types/src/codec/network.rs +++ b/crates/types/src/codec/network.rs @@ -23,7 +23,6 @@ use malachitebft_core_types::ValidatorProof; use malachitebft_sync::{self as sync}; use crate::codec::error::CodecError; -use crate::codec::impl_versioned_codec; use crate::codec::proto::ProtobufCodec; use crate::codec::versions::{ LivenessMsgVersion, ProposalPartVersion, SignedConsensusMsgVersion, StreamMessageVersion, @@ -442,76 +441,6 @@ mod tests { } } - /// XXX: remove after all nodes are upgraded to use versioning - #[test] - fn test_previous_codec_compatibility() { - let codec = NetCodec; - - let msg = create_test_vote(); - // NOTE: ProtobufCodec is used here because it is the previous codec used for encoding and decoding messages. - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - // LivenessMsg (Vote) - let msg = create_test_liveness_msg(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - // Proposal (SignedConsensusMsg::Proposal) - let msg = create_test_proposal(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - // ProposalPart messages (Init, Data, Fin variants) - let msg = create_test_proposal_part_init(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - let msg = create_test_proposal_part_data(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - let msg = create_test_proposal_part_fin(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - // StreamMessage (Data variant) - let msg = create_test_stream_message_data(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - // StreamMessage (Fin variant) - let msg = create_test_stream_message_fin(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - // sync::Status - let msg = create_test_sync_status(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded: sync::Status = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - // sync::Request - let msg = create_test_sync_request(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - - // sync::Response - let msg = create_test_sync_response(); - let encoded = ProtobufCodec.encode(&msg).expect("Failed to encode"); - let decoded = codec.decode(encoded).expect("Failed to decode"); - assert_eq!(msg, decoded); - } - #[test] fn test_decode_corrupted_legacy_message() { let codec = NetCodec; @@ -521,7 +450,6 @@ mod tests { let result: Result, _> = codec.decode(corrupted_bytes); assert!(result.is_err()); - // The logic should fall back to versioned decoding and fail with an "UnsupportedVersion" error. let err = result.unwrap_err(); assert!( matches!(err, CodecError::UnsupportedVersion(2)), @@ -534,7 +462,6 @@ mod tests { let result_v1: Result, _> = codec.decode(corrupted_bytes_v1); assert!(result_v1.is_err()); - // The logic should fall back, see the valid version byte, and then fail on protobuf decoding. let err_v1 = result_v1.unwrap_err(); assert!( matches!(err_v1, CodecError::Protobuf(_)), diff --git a/crates/types/src/codec/proto.rs b/crates/types/src/codec/proto.rs index 44fd220b..3370f3a3 100644 --- a/crates/types/src/codec/proto.rs +++ b/crates/types/src/codec/proto.rs @@ -20,9 +20,9 @@ use prost::Message; use malachitebft_app::engine::util::streaming::{StreamContent, StreamId, StreamMessage}; use malachitebft_core_consensus::{LivenessMsg, ProposedValue, SignedConsensusMsg}; use malachitebft_core_types::{ - CommitCertificate, CommitSignature, NilOrVal, PolkaCertificate, PolkaSignature, Round, - RoundCertificate, RoundCertificateType, RoundSignature, SignedExtension, SignedProposal, - SignedVote, ValidatorProof, Validity, + CommitCertificate, CommitSignature, ExtendedCommitCertificate, ExtendedCommitSignature, + NilOrVal, PolkaCertificate, PolkaSignature, Round, RoundCertificate, RoundCertificateType, + RoundSignature, SignedExtension, SignedProposal, SignedVote, ValidatorProof, Validity, }; use malachitebft_proto::{Error as ProtoError, Protobuf}; use malachitebft_signing_ed25519::Signature; @@ -602,6 +602,16 @@ pub(crate) fn decode_polka_certificate( pub fn decode_store_commit_certificate( proto: proto::store::CommitCertificate, ) -> Result { + if proto + .signatures + .iter() + .any(|signature| signature.extension.is_some()) + { + return Err(ProtoError::Other( + "Stored CommitCertificate signatures must not contain vote extensions".to_owned(), + )); + } + let certificate = decode_commit_certificate_fields( proto.height, proto.round, @@ -639,19 +649,67 @@ pub fn encode_store_commit_certificate( } pub fn decode_sync_commit_certificate( - proto: proto::sync::CommitCertificate, -) -> Result, ProtoError> { - decode_commit_certificate_fields(proto.height, proto.round, proto.value_id, proto.signatures) + certificate: proto::sync::CommitCertificate, +) -> Result, ProtoError> { + if certificate.signatures.len() > MAX_SIGNATURES_PER_CERTIFICATE { + return Err(ProtoError::Other(format!( + "ExtendedCommitCertificate signature count {} exceeds maximum {MAX_SIGNATURES_PER_CERTIFICATE}", + certificate.signatures.len(), + ))); + } + + let value_id = certificate + .value_id + .ok_or_else(|| ProtoError::missing_field::("value_id")) + .and_then(ValueId::from_proto)?; + + let commit_signatures = certificate + .signatures + .into_iter() + .map( + |sig| -> Result, ProtoError> { + let address = sig.validator_address.ok_or_else(|| { + ProtoError::missing_field::("validator_address") + })?; + let signature = sig.signature.ok_or_else(|| { + ProtoError::missing_field::("signature") + })?; + let extension = sig.extension.map(decode_extension).transpose()?; + Ok(ExtendedCommitSignature::new( + Address::from_proto(address)?, + decode_signature(signature)?, + extension, + )) + }, + ) + .collect::, _>>()?; + + Ok(ExtendedCommitCertificate { + height: Height::new(certificate.height), + round: Round::new(certificate.round), + value_id, + commit_signatures, + }) } pub fn encode_sync_commit_certificate( - certificate: &CommitCertificate, + certificate: &ExtendedCommitCertificate, ) -> Result { Ok(proto::sync::CommitCertificate { height: certificate.height.as_u64(), round: certificate.round.as_u32().expect("round should not be nil"), value_id: Some(certificate.value_id.to_proto()?), - signatures: encode_commit_signatures(&certificate.commit_signatures)?, + signatures: certificate + .commit_signatures + .iter() + .map(|sig| -> Result { + Ok(proto::sync::CommitSignature { + validator_address: Some(sig.address.to_proto()?), + signature: Some(encode_signature(&sig.signature)), + extension: sig.extension.as_ref().map(encode_extension).transpose()?, + }) + }) + .collect::, _>>()?, }) } @@ -706,6 +764,7 @@ fn encode_commit_signatures( Ok(proto::sync::CommitSignature { validator_address: Some(address), signature: Some(signature), + extension: None, }) }) .collect() @@ -837,7 +896,7 @@ pub fn decode_signed_proposal( #[cfg(test)] mod tests { use super::*; - use crate::{Address, Height}; + use crate::{Address, BlockHash, Height}; use malachitebft_core_types::{NilOrVal, Round, RoundSignature, VoteType}; use malachitebft_signing_ed25519::Signature; @@ -887,6 +946,56 @@ mod tests { ); } + #[test] + fn test_extended_commit_certificate_encode_decode() { + let certificate = ExtendedCommitCertificate { + height: Height::new(1), + round: Round::new(2), + value_id: ValueId::new(BlockHash::repeat_byte(3)), + commit_signatures: vec![ExtendedCommitSignature::new( + Address::new([4; 20]), + Signature::from_bytes([5; 64]), + Some(SignedExtension::new( + Bytes::from_static(b"extension"), + Signature::from_bytes([6; 64]), + )), + )], + }; + + let encoded = encode_sync_commit_certificate(&certificate).unwrap(); + let decoded = decode_sync_commit_certificate(encoded).unwrap(); + + assert_eq!(decoded, certificate); + } + + #[test] + fn test_extended_commit_certificate_decodes_without_extensions() { + let legacy = proto::sync::CommitCertificate { + height: 1, + round: 2, + value_id: Some(proto::ValueId { + block_hash: Bytes::from(vec![3; 32]), + }), + signatures: vec![proto::sync::CommitSignature { + validator_address: Some(proto::Address { + value: Bytes::from(vec![4; 20]), + }), + signature: Some(proto::Signature { + bytes: Bytes::from(vec![5; 64]), + }), + extension: None, + }], + }; + + // An unset optional field omits tag 3, matching bytes from the schema before it existed. + let encoded = legacy.encode_to_vec(); + let proto = proto::sync::CommitCertificate::decode(encoded.as_slice()).unwrap(); + let decoded = decode_sync_commit_certificate(proto).unwrap(); + + assert_eq!(decoded.commit_signatures.len(), 1); + assert!(decoded.commit_signatures[0].extension.is_none()); + } + #[test] fn test_validator_proof_encode_decode() { use malachitebft_core_types::ValidatorProof; @@ -965,6 +1074,67 @@ mod tests { ); } + #[test] + fn test_store_commit_certificate_rejects_vote_extensions() { + let certificate = proto::store::CommitCertificate { + height: 1, + round: 0, + value_id: Some(proto::ValueId { + block_hash: Bytes::from(vec![0u8; 32]), + }), + signatures: vec![proto::sync::CommitSignature { + validator_address: Some(proto::Address { + value: Bytes::from(vec![0u8; 20]), + }), + signature: Some(proto::Signature { + bytes: Bytes::from(vec![0u8; 64]), + }), + extension: Some(proto::Extension { + data: Bytes::from_static(b"extension"), + signature: Some(proto::Signature { + bytes: Bytes::from(vec![0u8; 64]), + }), + }), + }], + proposer: None, + extended: None, + }; + + let result = decode_store_commit_certificate(certificate); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("vote extensions")); + } + + #[test] + fn test_extended_commit_certificate_rejects_excessive_signatures() { + let oversized: Vec = (0..MAX_SIGNATURES_PER_CERTIFICATE + 1) + .map(|_| proto::sync::CommitSignature { + validator_address: Some(proto::Address { + value: Bytes::from(vec![0u8; 20]), + }), + signature: Some(proto::Signature { + bytes: Bytes::from(vec![0u8; 64]), + }), + extension: None, + }) + .collect(); + + let certificate = proto::sync::CommitCertificate { + height: 1, + round: 0, + value_id: Some(proto::ValueId { + block_hash: Bytes::from(vec![0u8; 32]), + }), + signatures: oversized, + }; + + let result = decode_sync_commit_certificate(certificate); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("exceeds maximum")); + } + #[test] fn test_commit_certificate_rejects_excessive_signatures() { let oversized: Vec = (0..MAX_SIGNATURES_PER_CERTIFICATE + 1) @@ -975,6 +1145,7 @@ mod tests { signature: Some(proto::Signature { bytes: Bytes::from(vec![0u8; 64]), }), + extension: None, }) .collect(); @@ -1085,6 +1256,7 @@ mod tests { signature: Some(proto::Signature { bytes: Bytes::from(vec![0u8; 64]), }), + extension: None, }) .collect(); diff --git a/crates/types/src/codec/wal.rs b/crates/types/src/codec/wal.rs index 285d9f18..387692f3 100644 --- a/crates/types/src/codec/wal.rs +++ b/crates/types/src/codec/wal.rs @@ -19,7 +19,6 @@ use malachitebft_core_consensus::{ProposedValue, SignedConsensusMsg}; use malachitebft_core_types::PolkaCertificate; -use crate::codec::impl_versioned_codec; use crate::codec::versions::{ PolkaCertificateVersion, ProposedValueVersion, SignedConsensusMsgVersion, }; @@ -28,19 +27,19 @@ use crate::ArcContext; #[derive(Copy, Clone, Debug)] pub struct WalCodec; -impl_versioned_codec!( +impl_versioned_codec_with_legacy_fallback!( WalCodec, SignedConsensusMsg, SignedConsensusMsgVersion, SignedConsensusMsgVersion::V1 ); -impl_versioned_codec!( +impl_versioned_codec_with_legacy_fallback!( WalCodec, ProposedValue, ProposedValueVersion, ProposedValueVersion::V1 ); -impl_versioned_codec!( +impl_versioned_codec_with_legacy_fallback!( WalCodec, PolkaCertificate, PolkaCertificateVersion, diff --git a/crates/types/src/commit_http.rs b/crates/types/src/commit_http.rs index 7d4d6fe9..fb0e6af1 100644 --- a/crates/types/src/commit_http.rs +++ b/crates/types/src/commit_http.rs @@ -148,4 +148,98 @@ mod tests { let back: HttpCommitSignature = serde_json::from_str(&json).unwrap(); assert_eq!(http, back); } + + #[test] + fn try_into_rejects_negative_round() { + let wire = HttpCommitCertificate { + height: 1, + round: -1, + block_hash: ValueId::new(BlockHash::ZERO), + signatures: vec![], + }; + assert!(wire.try_into_commit_certificate().is_err()); + } + + #[test] + fn try_into_rejects_round_exceeding_u32_max() { + // u32::MAX + 1 — smallest i64 that fails TryFrom for u32. + let wire = HttpCommitCertificate { + height: 1, + round: 4_294_967_296, + block_hash: ValueId::new(BlockHash::ZERO), + signatures: vec![], + }; + assert!(wire.try_into_commit_certificate().is_err()); + } + + #[test] + fn try_into_happy_path_with_signatures() { + let mut sig_bytes = [0u8; 64]; + sig_bytes[0] = 0xAA; + sig_bytes[63] = 0xBB; + let wire = HttpCommitCertificate { + height: 100, + round: 5, + block_hash: ValueId::new(BlockHash::ZERO), + signatures: vec![HttpCommitSignature { + address: Address::default(), + signature: sig_bytes.to_vec(), + }], + }; + let cert = wire.try_into_commit_certificate().unwrap(); + assert_eq!(cert.height.as_u64(), 100); + assert_eq!(cert.round.as_i64(), 5); + assert_eq!(cert.commit_signatures.len(), 1); + assert_eq!(cert.commit_signatures[0].signature.to_bytes(), sig_bytes); + } + + /// Hard-coded wire-format snapshot. If any field is renamed in + /// `HttpCommitCertificate` or `HttpCommitSignature`, this deserialization + /// fails — guarding against silent drift between producer and consumer. + #[test] + fn golden_json_field_names() { + let json = r#"{ + "height": 42, + "round": 3, + "block_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "signatures": [ + { + "address": "0x0000000000000000000000000000000000000000", + "signature": "AAAA" + } + ] + }"#; + let cert: HttpCommitCertificate = serde_json::from_str(json).unwrap(); + assert_eq!(cert.height, 42); + assert_eq!(cert.round, 3); + assert_eq!(cert.signatures.len(), 1); + assert_eq!(cert.signatures[0].signature, vec![0u8, 0u8, 0u8]); + } + + #[test] + fn http_commit_signature_serializes_as_base64_wire_format() { + let mut b = [0u8; 64]; + b[0] = 1; + b[63] = 2; + let http = HttpCommitSignature::from(CommitSignature { + address: Address::default(), + signature: Signature::from_bytes(b), + }); + + let value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&http).unwrap()).unwrap(); + let wire = value["signature"] + .as_str() + .expect("signature must serialize as a JSON string"); + + // Standard padded Base64 of the 64-byte signature, not hex. + let expected_b64 = format!("AQAA{}Ag==", "AAAA".repeat(20)); + assert_eq!( + wire, expected_b64, + "signature must be standard padded Base64" + ); + + let hex = format!("01{}02", "00".repeat(62)); + assert_ne!(wire, hex, "signature must not be hex-encoded"); + } } diff --git a/crates/types/src/config.rs b/crates/types/src/config.rs index 5167eda3..9174f4a1 100644 --- a/crates/types/src/config.rs +++ b/crates/types/src/config.rs @@ -25,6 +25,8 @@ pub use malachitebft_app::config::{ ConsensusConfig, LogFormat, LogLevel, LoggingConfig, MetricsConfig, NodeConfig, RuntimeConfig, ValueSyncConfig, }; +#[cfg(feature = "byzantine")] +pub use malachitebft_engine_byzantine::{ByzantineConfig, Trigger as ByzantineTrigger}; use crate::Height; @@ -70,6 +72,12 @@ pub struct Config { /// Signing config pub signing: SigningConfig, + + /// Byzantine behavior configuration (testnet only). + /// When present and active, the node exhibits configurable Byzantine faults. + #[cfg(feature = "byzantine")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub byzantine: Option, } impl Config { @@ -80,6 +88,10 @@ impl Config { if self.execution.persistence_backpressure_threshold == 0 { bail!("execution.persistence_backpressure_threshold must be greater than 0"); } + #[cfg(feature = "byzantine")] + if let Some(ref byz) = self.byzantine { + byz.validate()?; + } Ok(()) } } @@ -160,6 +172,13 @@ pub struct RpcConfig { /// Address to bind the RPC server to pub listen_addr: SocketAddr, + + /// Enable the admin routes (e.g. persistent-peer add/remove). + /// + /// Disabled by default: these routes mutate node state and must not be + /// reachable in the default configuration. Enable only on internal, + /// trusted interfaces. + pub admin: bool, } impl Default for RpcConfig { @@ -169,6 +188,7 @@ impl Default for RpcConfig { listen_addr: format!("127.0.0.1:{RPC_BASE_PORT}") .parse() .expect("valid socket address"), + admin: false, } } } @@ -398,5 +418,30 @@ mod tests { config.execution.persistence_backpressure_threshold = 0; assert!(config.validate().is_err()); } + + #[cfg(feature = "byzantine")] + #[test] + fn config_rejects_invalid_byzantine_config() { + use crate::config::{ByzantineConfig, ByzantineTrigger}; + + let mut config = Config::default(); + assert!(config.validate().is_ok()); + + // Mutually-exclusive triggers must be rejected. Without the + // Config::validate() call in start(), an ad-hoc + // `--byzantine=` would reach the engine with this combo. + config.byzantine = Some( + ByzantineConfig::new(Some(42)) + .with_drop_votes(ByzantineTrigger::Always) + .with_equivocate_votes(ByzantineTrigger::Always), + ); + let err = config + .validate() + .expect_err("drop_votes + equivocate_votes must be rejected"); + assert!( + format!("{err:#}").contains("drop_votes and equivocate_votes cannot both be set"), + "unexpected error: {err:#}" + ); + } } } diff --git a/crates/types/src/consensus_params.rs b/crates/types/src/consensus_params.rs index 1cb45162..2019bb0c 100644 --- a/crates/types/src/consensus_params.rs +++ b/crates/types/src/consensus_params.rs @@ -175,6 +175,7 @@ impl Default for ConsensusParams { precommit: bounds::timeout_precommit.default, precommit_delta: bounds::timeout_delta.default, rebroadcast: bounds::timeout_rebroadcast.default, + ..LinearTimeouts::default() }, } } @@ -205,6 +206,7 @@ mod tests { assert_eq!(linear.precommit, params.timeouts.precommit); assert_eq!(linear.precommit_delta, params.timeouts.precommit_delta); assert_eq!(linear.rebroadcast, params.timeouts.rebroadcast); + assert_eq!(linear.max_timeout, params.timeouts.max_timeout); } #[test] @@ -220,6 +222,7 @@ mod tests { precommit: Duration::from_secs(3), // within 1s..=5s precommit_delta: Duration::from_millis(300), // within 250ms..=1s rebroadcast: Duration::from_secs(5), // within 3s..=30s + ..LinearTimeouts::default() }, ); @@ -247,6 +250,7 @@ mod tests { precommit: Duration::ZERO, // below 250ms min precommit_delta: Duration::ZERO, // below 50ms min rebroadcast: Duration::from_millis(500), // below 1s min + ..LinearTimeouts::default() }, ); @@ -279,6 +283,7 @@ mod tests { precommit: Duration::from_secs(100), // above 10s max precommit_delta: Duration::from_secs(10), // above 1s max rebroadcast: Duration::from_secs(100), // above 30s max + ..LinearTimeouts::default() }, ); @@ -311,6 +316,7 @@ mod tests { precommit: Duration::from_secs(1), precommit_delta: Duration::from_millis(500), rebroadcast: Duration::from_secs(5), + ..LinearTimeouts::default() }, ); @@ -333,6 +339,7 @@ mod tests { precommit: Duration::from_secs(1), precommit_delta: Duration::from_millis(500), rebroadcast: Duration::from_secs(5), + ..LinearTimeouts::default() }, ); @@ -352,6 +359,7 @@ mod tests { precommit: Duration::from_secs(1), precommit_delta: Duration::from_millis(500), rebroadcast: Duration::from_secs(5), + ..LinearTimeouts::default() }, ); @@ -371,6 +379,7 @@ mod tests { precommit: Duration::from_secs(3), // within bounds precommit_delta: Duration::from_millis(500), // within bounds rebroadcast: Duration::from_secs(5), + ..LinearTimeouts::default() }, ); @@ -398,6 +407,7 @@ mod tests { precommit: Duration::from_secs(4), // valid precommit_delta: Duration::from_secs(5), // invalid - above max rebroadcast: Duration::from_secs(10), // valid + ..LinearTimeouts::default() }, ); diff --git a/crates/types/src/context.rs b/crates/types/src/context.rs index 0ae08881..37cd5e1e 100644 --- a/crates/types/src/context.rs +++ b/crates/types/src/context.rs @@ -18,6 +18,16 @@ use bytes::Bytes; use malachitebft_core_types::{Context, LinearTimeouts, NilOrVal, Round}; +#[cfg(feature = "byzantine")] +use std::sync::{Arc, Mutex}; + +#[cfg(feature = "byzantine")] +use malachitebft_engine_byzantine::{Amnesia, Trigger}; +#[cfg(feature = "byzantine")] +use rand::rngs::StdRng; +#[cfg(feature = "byzantine")] +use rand::SeedableRng; + use crate::address::*; use crate::height::*; use crate::proposal::*; @@ -28,15 +38,84 @@ use crate::validator_set::*; use crate::value::*; use crate::vote::*; -#[derive(Copy, Clone, Debug, Default)] +/// Byzantine state bundle attached to `ArcContext` when the `byzantine` +/// feature is enabled. Owns the context-generic amnesia state machine +/// plus the `force_precommit_nil` trigger/RNG. Only non-`None` for tests +/// that opt into byzantine behavior via config. +#[cfg(feature = "byzantine")] +pub struct ByzantineState { + /// Amnesia state machine (`ignore_locks`). + pub amnesia: Amnesia, + /// When to rewrite non-nil precommits into nil precommits. + pub force_precommit_nil: Trigger, + /// The node's own validator address. `new_precommit` only rewrites for + /// this address so certificate-verification reconstructions for other + /// validators are left intact. + pub self_address: Address, + /// RNG for evaluating `force_precommit_nil`. Amnesia owns its own RNG. + rng: Mutex, +} + +#[cfg(feature = "byzantine")] +impl ByzantineState { + pub fn new( + ignore_locks: Trigger, + force_precommit_nil: Trigger, + self_address: Address, + seed: Option, + ) -> Self { + let rng = match seed { + Some(s) => StdRng::seed_from_u64(s), + None => StdRng::from_entropy(), + }; + Self { + amnesia: Amnesia::new(ignore_locks, seed), + force_precommit_nil, + self_address, + rng: Mutex::new(rng), + } + } + + fn should_force_precommit_nil(&self, height: Height, round: Round) -> bool { + self.force_precommit_nil + .fires(height, round, &mut self.rng.lock().expect("poisoned rng")) + } +} + +#[cfg(feature = "byzantine")] +impl std::fmt::Debug for ByzantineState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ByzantineState") + .field("force_precommit_nil", &self.force_precommit_nil) + .field("self_address", &self.self_address) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Debug, Default)] +// Preserve the pre-byzantine `Copy` impl for non-byzantine builds. When the +// feature is enabled, `Option>` is not `Copy`, so the +// derive is gated off. +#[cfg_attr(not(feature = "byzantine"), derive(Copy))] pub struct ArcContext { pub proposer_selector: RoundRobin, + #[cfg(feature = "byzantine")] + pub byzantine: Option>, } impl ArcContext { pub fn new() -> Self { Self::default() } + + /// Attach a [`ByzantineState`] bundle; used by arc-node's startup to + /// wire amnesia + force-precommit-nil into the context when the + /// `[byzantine]` config section is active. + #[cfg(feature = "byzantine")] + pub fn with_byzantine(mut self, byzantine: Arc) -> Self { + self.byzantine = Some(byzantine); + self + } } impl Context for ArcContext { @@ -80,6 +159,15 @@ impl Context for ArcContext { value_id: NilOrVal, address: Address, ) -> Vote { + #[cfg(feature = "byzantine")] + let value_id = match (&value_id, &self.byzantine) { + (NilOrVal::Nil, Some(byz)) => byz + .amnesia + .try_override_nil_prevote(height, round) + .map(NilOrVal::Val) + .unwrap_or(value_id), + _ => value_id, + }; Vote::new_prevote(height, round, value_id, address) } @@ -90,6 +178,133 @@ impl Context for ArcContext { value_id: NilOrVal, address: Address, ) -> Vote { + #[cfg(feature = "byzantine")] + if let Some(byz) = &self.byzantine { + if address == byz.self_address + && matches!(value_id, NilOrVal::Val(_)) + && byz.should_force_precommit_nil(height, round) + { + tracing::warn!( + %height, %round, + "BYZANTINE: Forcing precommit nil (rewriting non-nil precommit)" + ); + return Vote::new_precommit(height, round, NilOrVal::Nil, address); + } + } Vote::new_precommit(height, round, value_id, address) } } + +#[cfg(all(test, feature = "byzantine"))] +mod byzantine_tests { + use super::*; + use crate::BlockHash; + + fn addr(byte: u8) -> Address { + Address::repeat_byte(byte) + } + + fn value_id(byte: u8) -> ValueId { + ValueId::new(BlockHash::repeat_byte(byte)) + } + + fn ctx_with_byzantine( + ignore_locks: Trigger, + force_precommit_nil: Trigger, + self_address: Address, + ) -> ArcContext { + let state = Arc::new(ByzantineState::new( + ignore_locks, + force_precommit_nil, + self_address, + Some(42), + )); + ArcContext::default().with_byzantine(state) + } + + #[test] + fn new_prevote_amnesia_overrides_nil_with_recorded_value() { + let self_addr = addr(0x01); + let ctx = ctx_with_byzantine(Trigger::Always, Trigger::Never, self_addr); + let h = Height::new(1); + let r = Round::new(0); + let vid = value_id(0xAB); + + ctx.byzantine + .as_ref() + .unwrap() + .amnesia + .record_proposed_value(h, r, vid); + + let vote = ctx.new_prevote(h, r, NilOrVal::Nil, self_addr); + assert!(matches!(vote.value, NilOrVal::Val(v) if v == vid)); + } + + #[test] + fn new_prevote_amnesia_leaves_non_nil_unchanged() { + let self_addr = addr(0x01); + let ctx = ctx_with_byzantine(Trigger::Always, Trigger::Never, self_addr); + let h = Height::new(1); + let r = Round::new(0); + let vid = value_id(0xCD); + + let vote = ctx.new_prevote(h, r, NilOrVal::Val(vid), self_addr); + assert!(matches!(vote.value, NilOrVal::Val(v) if v == vid)); + } + + #[test] + fn new_prevote_without_byzantine_state_leaves_nil_unchanged() { + let ctx = ArcContext::default(); + let vote = ctx.new_prevote(Height::new(1), Round::new(0), NilOrVal::Nil, addr(0x02)); + assert!(matches!(vote.value, NilOrVal::Nil)); + } + + #[test] + fn new_precommit_force_nil_rewrites_val_to_nil_for_self() { + let self_addr = addr(0x01); + let ctx = ctx_with_byzantine(Trigger::Never, Trigger::Always, self_addr); + let vote = ctx.new_precommit( + Height::new(1), + Round::new(0), + NilOrVal::Val(value_id(0xAB)), + self_addr, + ); + assert!(matches!(vote.value, NilOrVal::Nil)); + } + + #[test] + fn new_precommit_force_nil_leaves_other_validators_unchanged() { + // Certificate reconstruction synthesises precommits for other validators; + // those must never be rewritten. + let self_addr = addr(0x01); + let other_addr = addr(0x02); + let ctx = ctx_with_byzantine(Trigger::Never, Trigger::Always, self_addr); + let vid = value_id(0xAB); + + let vote = ctx.new_precommit( + Height::new(1), + Round::new(0), + NilOrVal::Val(vid), + other_addr, + ); + assert!(matches!(vote.value, NilOrVal::Val(v) if v == vid)); + } + + #[test] + fn new_precommit_force_nil_leaves_nil_unchanged() { + let self_addr = addr(0x01); + let ctx = ctx_with_byzantine(Trigger::Never, Trigger::Always, self_addr); + let vote = ctx.new_precommit(Height::new(1), Round::new(0), NilOrVal::Nil, self_addr); + assert!(matches!(vote.value, NilOrVal::Nil)); + } + + #[test] + fn new_precommit_trigger_never_leaves_val_unchanged() { + let self_addr = addr(0x01); + let ctx = ctx_with_byzantine(Trigger::Never, Trigger::Never, self_addr); + let vid = value_id(0xAB); + + let vote = ctx.new_precommit(Height::new(1), Round::new(0), NilOrVal::Val(vid), self_addr); + assert!(matches!(vote.value, NilOrVal::Val(v) if v == vid)); + } +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index d092efa0..2d7d564d 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -22,9 +22,9 @@ mod address; mod aliases; mod certificate; pub mod commit_http; -mod config; +pub mod config; mod consensus_params; -mod context; +pub mod context; mod height; mod proposal; pub mod proposal_monitor; diff --git a/crates/types/src/proposal_monitor.rs b/crates/types/src/proposal_monitor.rs index 8804447a..755e5aae 100644 --- a/crates/types/src/proposal_monitor.rs +++ b/crates/types/src/proposal_monitor.rs @@ -27,7 +27,7 @@ //! By design, monitoring data is only stored for round-0 proposals. use std::time::SystemTime; -use tracing::warn; +use tracing::{debug, info, warn}; use crate::{Address, Height, ValueId}; @@ -118,16 +118,26 @@ impl ProposalMonitor { /// Record that proposal was received. /// Takes precedence over synced value. pub fn record_proposal(&mut self, value_id: ValueId) { - // FIXME: this log message should not be produced here. + // FIXME: these log messages should not be produced here. if let Some(first_value) = self.value_id && !self.synced { - warn!( - height = %self.height, - %first_value, - new_value = %value_id, - "Equivocating proposal at round 0" - ); + if first_value == value_id { + // Same proposal seen twice at round 0 (e.g. a re-broadcast) — + // benign, keep the first recording. + debug!( + height = %self.height, + %value_id, + "Duplicate proposal at round 0, ignoring" + ); + } else { + warn!( + height = %self.height, + %first_value, + new_value = %value_id, + "Equivocating proposal at round 0" + ); + } return; } self.proposal_receive_time = Some(SystemTime::now()); @@ -135,6 +145,41 @@ impl ProposalMonitor { self.synced = false; } + /// Fill in the value id for a proposal whose receive time was recorded + /// early, once the parts are reassembled. + /// + /// Never touches `proposal_receive_time`, so the (negative) delay is kept. + pub fn attach_assembled_value_id(&mut self, value_id: ValueId) { + if self.synced { + return; + } + match self.value_id { + None => { + self.value_id = Some(value_id); + info!( + height = %self.height, + %value_id, + "Recorded value id for proposal received before round-0 start" + ); + } + Some(existing) if existing == value_id => { + debug!( + height = %self.height, + %value_id, + "Value id already recorded for this height, ignoring duplicate" + ); + } + Some(existing) => { + warn!( + height = %self.height, + first_value = %existing, + new_value = %value_id, + "Equivocating proposal (assembled from parts) at round 0" + ); + } + } + } + /// Check if the decided value matches the recorded proposal. fn is_successful(&self, decided_value_id: &ValueId) -> bool { self.value_id @@ -279,4 +324,79 @@ mod tests { assert!(monitor.synced); } + + #[test] + fn test_record_proposal_duplicate_same_value_is_benign() { + // A re-broadcast of the same round-0 proposal must not be treated as equivocation + let mut monitor = ProposalMonitor::new(Height::new(1), test_address(), SystemTime::now()); + let value = test_value_id(0x11); + + monitor.record_proposal(value); + let time1 = monitor.proposal_receive_time.unwrap(); + + monitor.record_proposal(value); + + assert_eq!(monitor.value_id, Some(value)); + assert_eq!(monitor.proposal_receive_time, Some(time1)); + } + + #[test] + fn test_attach_assembled_value_id_when_unset() { + // Simulates the pending-parts path: receive time was set early, value + // id is still None, then the reassembled value is attached without + // disturbing the receive time. + let start = SystemTime::now(); + let mut monitor = ProposalMonitor::new(Height::new(1), test_address(), start); + let receive = start - Duration::from_millis(120); + monitor.proposal_receive_time = Some(receive); + + let value = test_value_id(0xA1); + monitor.attach_assembled_value_id(value); + + assert_eq!(monitor.value_id, Some(value)); + assert_eq!( + monitor.proposal_receive_time, + Some(receive), + "attach must not overwrite the (earlier) receive time", + ); + assert!(!monitor.synced); + } + + #[test] + fn test_attach_assembled_value_id_same_value_is_benign() { + let mut monitor = ProposalMonitor::new(Height::new(1), test_address(), SystemTime::now()); + let value = test_value_id(0xA2); + + monitor.attach_assembled_value_id(value); + monitor.attach_assembled_value_id(value); + + assert_eq!(monitor.value_id, Some(value)); + } + + #[test] + fn test_attach_assembled_value_id_different_value_keeps_first() { + // Genuine round-0 equivocation: two distinct values assembled for the same height. + // The first recording wins; the second is flagged (logged) but does not overwrite. + let mut monitor = ProposalMonitor::new(Height::new(1), test_address(), SystemTime::now()); + let first = test_value_id(0xA3); + let second = test_value_id(0xB3); + + monitor.attach_assembled_value_id(first); + monitor.attach_assembled_value_id(second); + + assert_eq!(monitor.value_id, Some(first)); + } + + #[test] + fn test_attach_assembled_value_id_noop_when_synced() { + // Value id and success are not meaningful for synced heights, so attach + // is a no-op. + let mut monitor = ProposalMonitor::new(Height::new(1), test_address(), SystemTime::now()); + monitor.mark_synced(); + + monitor.attach_assembled_value_id(test_value_id(0xA4)); + + assert!(monitor.value_id.is_none()); + assert!(monitor.synced); + } } diff --git a/crates/types/src/proposal_parts.rs b/crates/types/src/proposal_parts.rs index 8c2f0600..8e2a0bb1 100644 --- a/crates/types/src/proposal_parts.rs +++ b/crates/types/src/proposal_parts.rs @@ -18,7 +18,7 @@ use thiserror::Error; use malachitebft_proto::{Error as ProtoError, Protobuf}; -use crate::{Address, Height, ProposalData, ProposalFin, ProposalInit, ProposalPart, Round}; +use crate::{Address, Height, ProposalData, ProposalFin, ProposalInit, ProposalPart, Round, B256}; #[derive(Copy, Clone, Debug, PartialEq, Eq, Error)] pub enum ProposalPartsError { @@ -84,7 +84,7 @@ impl ProposalParts { self.init.round } - /// Return the adderss of the proposer of these proposal parts + /// Return the address of the proposer of these proposal parts pub const fn proposer(&self) -> Address { self.init.proposer } @@ -129,6 +129,29 @@ impl ProposalParts { hasher.finalize().into() } + + /// Storage key for the pending proposal-parts table. + /// + /// Hashes [`Self::hash`] together with the fields it omits: + /// `proposer`, `pol_round`, and the `Fin` signature. Streams with the same + /// signed hash but different unsigned fields therefore occupy distinct rows. + /// + /// ## Important + /// ⚠️ Not a signing hash. It must never be used to produce or verify a signature. + pub fn pending_key(&self) -> (Height, Round, B256) { + use sha3::{Digest, Keccak256}; + + let mut hasher = Keccak256::new(); + hasher.update(self.hash()); + hasher.update(self.proposer().into_inner()); + hasher.update(self.init().pol_round.as_i64().to_be_bytes()); + hasher.update(self.fin().signature.to_bytes()); + ( + self.height(), + self.round(), + B256::new(hasher.finalize().into()), + ) + } } impl Protobuf for ProposalParts { diff --git a/deployments/Dockerfile.consensus b/deployments/Dockerfile.consensus index 5caca840..6e0d3aa4 100644 --- a/deployments/Dockerfile.consensus +++ b/deployments/Dockerfile.consensus @@ -1,7 +1,7 @@ ############### # cargo chef ############### -FROM public.ecr.aws/docker/library/rust:1.91.1-bookworm AS chef +FROM public.ecr.aws/docker/library/rust:1.93.0-bookworm AS chef # Install any custom CA certificates from the certs named context. RUN --mount=type=bind,from=certs,target=/usr/local/share/ca-certificates/ \ @@ -52,7 +52,14 @@ COPY --from=planner /src/arc-node-consensus-recipe.json arc-node-consensus-recip RUN --mount=type=cache,id=malachite-cargo-registry,sharing=locked,target=/usr/local/cargo/registry \ --mount=type=cache,id=malachite-cargo-git,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,id=malachite-cargo-target,sharing=locked,target=/app/target \ + --mount=type=secret,id=github_token,required=false \ # Cook the recipe produced by the planner, namely compile dependencies + set -eu; \ + if [ -s /run/secrets/github_token ]; then \ + token="$(cat /run/secrets/github_token)"; \ + git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"; \ + trap 'git config --global --remove-section url."https://${token}@github.com/" || true' EXIT; \ + fi; \ cargo chef cook --bin arc-node-consensus --bin arc-snapshots --profile $BUILD_PROFILE --features "$FEATURES" --recipe-path arc-node-consensus-recipe.json # Per-commit git version args come after dep-cook — moving them here prevents the @@ -76,6 +83,13 @@ COPY . . RUN --mount=type=cache,id=malachite-cargo-registry,sharing=locked,target=/usr/local/cargo/registry \ --mount=type=cache,id=malachite-cargo-git,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,id=malachite-cargo-target,sharing=locked,target=/app/target \ + --mount=type=secret,id=github_token,required=false \ + set -eu; \ + if [ -s /run/secrets/github_token ]; then \ + token="$(cat /run/secrets/github_token)"; \ + git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"; \ + trap 'git config --global --remove-section url."https://${token}@github.com/" || true' EXIT; \ + fi; \ cargo build --bin arc-node-consensus --bin arc-snapshots --profile $BUILD_PROFILE --features "$FEATURES" --locked && \ # Copy built binaries to a temporary, non-cached location to make them accessible to the next stage. # Cargo outputs 'dev' profile to 'debug' directory, but other profiles use their own name diff --git a/deployments/Dockerfile.engine-bench b/deployments/Dockerfile.engine-bench index 100268c0..6dcf3317 100644 --- a/deployments/Dockerfile.engine-bench +++ b/deployments/Dockerfile.engine-bench @@ -1,7 +1,7 @@ ############### # cargo chef ############### -FROM public.ecr.aws/docker/library/rust:1.91.1-bookworm AS chef +FROM public.ecr.aws/docker/library/rust:1.93.0-bookworm AS chef # Install any custom CA certificates from the certs named context. RUN --mount=type=bind,from=certs,target=/usr/local/share/ca-certificates/ \ @@ -47,6 +47,14 @@ COPY --from=planner /src/arc-engine-bench-recipe.json arc-engine-bench-recipe.js RUN --mount=type=cache,id=bench-cargo-registry,sharing=locked,target=/usr/local/cargo/registry \ --mount=type=cache,id=bench-cargo-git,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,id=bench-cargo-target,sharing=locked,target=/app/target \ + --mount=type=secret,id=github_token,required=false \ + # Cook the recipe produced by the planner, namely compile dependencies + set -eu; \ + if [ -s /run/secrets/github_token ]; then \ + token="$(cat /run/secrets/github_token)"; \ + git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"; \ + trap 'git config --global --remove-section url."https://${token}@github.com/" || true' EXIT; \ + fi; \ cargo chef cook --bin arc-engine-bench --profile $BUILD_PROFILE --recipe-path arc-engine-bench-recipe.json # Per-commit git version args come after dep-cook — moving them here prevents the @@ -65,6 +73,13 @@ COPY . . RUN --mount=type=cache,id=bench-cargo-registry,sharing=locked,target=/usr/local/cargo/registry \ --mount=type=cache,id=bench-cargo-git,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,id=bench-cargo-target,sharing=locked,target=/app/target \ + --mount=type=secret,id=github_token,required=false \ + set -eu; \ + if [ -s /run/secrets/github_token ]; then \ + token="$(cat /run/secrets/github_token)"; \ + git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"; \ + trap 'git config --global --remove-section url."https://${token}@github.com/" || true' EXIT; \ + fi; \ cargo build --bin arc-engine-bench --profile $BUILD_PROFILE --locked && \ if [ "$BUILD_PROFILE" = "dev" ]; then TARGET_DIR="debug"; else TARGET_DIR="$BUILD_PROFILE"; fi && \ cp /app/target/$TARGET_DIR/arc-engine-bench /tmp/arc-engine-bench diff --git a/deployments/Dockerfile.execution b/deployments/Dockerfile.execution index 0e9eb5fe..68815803 100644 --- a/deployments/Dockerfile.execution +++ b/deployments/Dockerfile.execution @@ -1,7 +1,7 @@ ############### # cargo chef ############### -FROM public.ecr.aws/docker/library/rust:1.91.1-bookworm AS chef +FROM public.ecr.aws/docker/library/rust:1.93.0-bookworm AS chef # Install any custom CA certificates from the certs named context. RUN --mount=type=bind,from=certs,target=/usr/local/share/ca-certificates/ \ @@ -53,7 +53,14 @@ COPY --from=planner /src/arc-node-execution-recipe.json arc-node-execution-recip RUN --mount=type=cache,id=reth-cargo-registry,sharing=locked,target=/usr/local/cargo/registry \ --mount=type=cache,id=reth-cargo-git,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,id=reth-cargo-target,sharing=locked,target=/app/target \ + --mount=type=secret,id=github_token,required=false \ # Cook the recipe produced by the planner, namely compile dependencies + set -eu; \ + if [ -s /run/secrets/github_token ]; then \ + token="$(cat /run/secrets/github_token)"; \ + git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"; \ + trap 'git config --global --remove-section url."https://${token}@github.com/" || true' EXIT; \ + fi; \ cargo chef cook --bin arc-node-execution --bin arc-snapshots --profile $BUILD_PROFILE --features "$FEATURES" --recipe-path arc-node-execution-recipe.json # Per-commit git version args come after dep-cook — moving them here prevents the @@ -77,6 +84,13 @@ COPY . . RUN --mount=type=cache,id=reth-cargo-registry,sharing=locked,target=/usr/local/cargo/registry \ --mount=type=cache,id=reth-cargo-git,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,id=reth-cargo-target,sharing=locked,target=/app/target \ + --mount=type=secret,id=github_token,required=false \ + set -eu; \ + if [ -s /run/secrets/github_token ]; then \ + token="$(cat /run/secrets/github_token)"; \ + git config --global url."https://${token}@github.com/".insteadOf "https://github.com/"; \ + trap 'git config --global --remove-section url."https://${token}@github.com/" || true' EXIT; \ + fi; \ cargo build --bin arc-node-execution --bin arc-snapshots --profile $BUILD_PROFILE --features "$FEATURES" --locked && \ # Cargo outputs 'dev' profile to 'debug' directory, but other profiles use their own name if [ "$BUILD_PROFILE" = "dev" ]; then TARGET_DIR="debug"; else TARGET_DIR="$BUILD_PROFILE"; fi && \ diff --git a/deployments/arc_consensus.yaml b/deployments/arc_consensus.yaml index 2cc458b8..de2dce4e 100644 --- a/deployments/arc_consensus.yaml +++ b/deployments/arc_consensus.yaml @@ -12,6 +12,8 @@ services: BUILD_PROFILE: ${BUILD_PROFILE:-release} FEATURES: ${CL_FEATURES:-} ARC_IDEMPOTENT_BUILD: ${ARC_IDEMPOTENT_BUILD:-false} + secrets: + - github_token environment: ARC_HALT_AT_BLOCK_HEIGHT: 0 # Set it to a non-zero value to plan a coordinated update # ARC_CONSENSUS_DB_CACHE_SIZE_BYTES: 1073741824 # 1 GiB (default) @@ -26,3 +28,7 @@ services: networks: arc_network: + +secrets: + github_token: + environment: GITHUB_TOKEN diff --git a/deployments/arc_execution.yaml b/deployments/arc_execution.yaml index f9eda6fc..77b33964 100644 --- a/deployments/arc_execution.yaml +++ b/deployments/arc_execution.yaml @@ -13,6 +13,8 @@ services: CHAIN: ${CHAIN:-localdev} FEATURES: ${EL_FEATURES:-default js-tracer} ARC_IDEMPOTENT_BUILD: ${ARC_IDEMPOTENT_BUILD:-false} + secrets: + - github_token image: arc_execution:latest cap_add: - NET_ADMIN @@ -66,3 +68,7 @@ services: networks: arc_network: + +secrets: + github_token: + environment: GITHUB_TOKEN diff --git a/deployments/docker-compose.yml b/deployments/docker-compose.yml index 11b440f5..d14299f7 100644 --- a/deployments/docker-compose.yml +++ b/deployments/docker-compose.yml @@ -1,6 +1,6 @@ # Docker Compose for running an Arc node in follow mode. # -# Start: docker compose up -d (first run downloads ~84 GB of snapshots) +# Start: docker compose up -d (first run downloads snapshots) # Stop: docker compose down # Logs: docker compose logs -f # @@ -12,13 +12,25 @@ services: # --- Initialization (runs once, then exits) --- - # Download EL and CL snapshots (the CLI skips if data already exists) + # Download the newest complete v2 EL and CL pair. This runs on every + # `docker compose up`, not only the first, and never falls back to v1. A layer + # that already holds the selected snapshot is left alone, so a normal restart + # finishes in seconds. Two cases do not: + # once a newer snapshot is published a restart re-downloads the execution layer + # in full, and after an interrupted download this service exits non-zero and the + # stack does not start until --force is added below. Both are covered in + # docs/running-an-arc-node.md#start. + # + # Automatic resolution always selects a reth manifest. --el-profile defaults + # to minimal, but this service passes full to match the arc-execution pruning + # preset below. Omitting it would restore less history than configured. arc-snapshots: image: ${ARC_EXECUTION_IMAGE:?ARC_EXECUTION_IMAGE must be set} entrypoint: ["/usr/local/bin/arc-snapshots"] command: - download - --chain=arc-testnet + - --el-profile=full - --execution-path=/data/execution - --consensus-path=/data/consensus volumes: @@ -67,7 +79,7 @@ services: - --metrics=0.0.0.0:9001 - --disable-discovery - --enable-arc-rpc - - --rpc.forwarder=https://rpc.quicknode.testnet.arc.network/ + - --rpc.forwarder=https://rpc.testnet.arc.io/ - --log.file.directory=/data/execution/logs volumes: - ${ARC_HOME}/execution:/data/execution @@ -90,9 +102,9 @@ services: - --execution-socket=/sockets/auth.ipc - --rpc.addr=0.0.0.0:31000 - --follow - - --follow.endpoint=https://rpc.drpc.testnet.arc.network,wss=rpc.drpc.testnet.arc.network - - --follow.endpoint=https://rpc.quicknode.testnet.arc.network,wss=rpc.quicknode.testnet.arc.network - - --follow.endpoint=https://rpc.blockdaemon.testnet.arc.network,wss=rpc.blockdaemon.testnet.arc.network/websocket + - --follow.endpoint=https://rpc.testnet.arc.io,wss=rpc.testnet.arc.io + - --follow.endpoint=https://rpc.drpc.testnet.arc.io,wss=rpc.drpc.testnet.arc.io + - --follow.endpoint=https://rpc.blockdaemon.testnet.arc.io,wss=rpc.blockdaemon.testnet.arc.io/websocket - --full - --execution-persistence-backpressure - --execution-persistence-backpressure-threshold=50 diff --git a/deployments/monitoring/config-blockscout/frontend/.curlrc b/deployments/monitoring/config-blockscout/frontend/.curlrc index 5bcdf034..5688b97d 100644 --- a/deployments/monitoring/config-blockscout/frontend/.curlrc +++ b/deployments/monitoring/config-blockscout/frontend/.curlrc @@ -1,2 +1,2 @@ -# Disable TLS Verification for asset download; needed due to Zscaler +# Disable TLS certificate verification for asset download (needed behind TLS-inspecting proxies) insecure \ No newline at end of file diff --git a/docker-bake.hcl b/docker-bake.hcl index 4f730360..361360a0 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -38,6 +38,13 @@ target "meta-target" { certs = "./deployments/certs" } + # Lets the cargo-chef cook/build stages authenticate clones of private git + # dependencies (e.g. crcl-main/circle-chain-consensus). Sourced from the + # GITHUB_TOKEN env var the build workflow exports via pre_build_command. + secret = [ + "id=github_token,env=GITHUB_TOKEN", + ] + args = { GIT_COMMIT_HASH = GIT_COMMIT_HASH GIT_VERSION = GIT_VERSION diff --git a/docs/adr/0004-base-fee-validation.md b/docs/adr/0004-base-fee-validation.md index 7438c50b..3be52023 100644 --- a/docs/adr/0004-base-fee-validation.md +++ b/docs/adr/0004-base-fee-validation.md @@ -5,23 +5,23 @@ | Status | Draft | | Author(s) | @asoghoian | | Created | 2026-03-03 | -| Updated | 2026-05-12 | +| Updated | 2026-06-08 | | Supersedes | - | | Superseded by | - | ## Context -On Arc, the base fee calculation combines: 1) an EIP-1559-style algorithm, 2) several at-runtime-governance-tunable values, and 3) an exponentially-smoothed view of historical gas use. +On Arc, the base fee calculation combines: 1) an EIP-1559-style algorithm, 2) several at-runtime-governance-tunable values, and 3) an exponentially-smoothed view of historical gas use. The value of 1) is that it is familiar and well-understood. The value of 2) is to enable rapid adjusting of fee parameters and strictly enforcing block gas limits through governance. The value of 3) is to dampen fee increases against sudden shocks in usage with Arc's fast blocktimes. -The combination of these makes it challenging for integrators to precisely understand the next block base fee as the calculation is more involved. To address this, Arc includes the next block base fee in the header, computed at the end of the parent's block execution, since it is fully known at that point. +The combination of these makes it challenging for integrators to precisely understand the next block base fee as the calculation is more involved. To address this, Arc includes the next block base fee in the header, computed at the end of the parent's block execution, since it is fully known at that point. Currently, the implementation has several gaps in validation. This ADR, similar to ADR-003, seeks to document solutions to these gaps. ### Gap 1: No validation of the base fee computation -There is currently no validation of the base fee calculation, just a check that the parent's header contains the next block's base fee. This is clearly insufficient -- a proposer could miscalculate a base fee, store it in the block header, and force the next proposer to use it. +There is currently no validation of the base fee calculation, just a check that the parent's header contains the next block's base fee. This is clearly insufficient -- a proposer could miscalculate a base fee, store it in the block header, and force the next proposer to use it. ### Gap 2: The current fallback behavior is complex for integrators @@ -31,7 +31,7 @@ This adds complexity as: 1) an integrator may not know how to "derive" the base ## Decision -Always include the next block base fee, use a consistent algorithm, and apply strict validation. +Always include the next block base fee, use a consistent algorithm, and apply strict validation. ### Chainspec Updates @@ -41,8 +41,8 @@ Two new types are attached to the chainspec via `base_fee_config(block_height)`: /// Bounds on the three calculation parameters from ProtocolConfig. struct BaseFeeCalcParams { alpha: u64, // EMA smoothing factor [0, 100] - k_rate: u64, // Max change rate in basis points (200 = 2%) - elasticity_multiplier: u64, // Target gas ratio in basis points (5000 = 50%) + k_rate: u64, // Base fee adjustment coefficient, in basis points (200 = 2%) + inverse_elasticity_multiplier: u64, // Target gas ratio in basis points (5000 = 50%) } /// Complete base fee configuration for a network. @@ -72,7 +72,7 @@ The two absolute bounds control the *output*: after computation and after the Pr | testnet | 1 | 20 | 100 | | mainnet | 1 | 20 | 100 | -**k_rate** — max base fee change rate per block (basis points). 200 = 2%. +**k_rate** — Base fee adjustment coefficient per block (basis points). 200 = 2%. | Network | min | default | max | |---------|-----|---------|-----| @@ -81,7 +81,7 @@ The two absolute bounds control the *output*: after computation and after the Pr | testnet | 1 | 200 | 1000 | | mainnet | 1 | 200 | 1000 | -**elasticity_multiplier** — target gas utilization (basis points). 5000 = 50%. +**inverse_elasticity_multiplier** — target gas utilization (basis points). 5000 = 50%. | Network | min | default | max | |---------|-----|---------|-----| @@ -119,14 +119,14 @@ else: calc = BaseFeeCalcParams( alpha = validated(fee_params.alpha, alpha), k_rate = validated(fee_params.kRate, k_rate), - elasticity_multiplier = validated(fee_params.elasticityMultiplier, elasticity_multiplier), - ) + inverse_elasticity_multiplier = validated(fee_params.inverseElasticityMultiplier, inverse_elasticity_multiplier), + ) # 1. Compute smoothed gas smoothed_gas = ema(parent_smoothed_gas, block_gas_used, calc.alpha) # 2. Compute next base fee -next_base_fee = arc_calc_next_block_base_fee(smoothed_gas, gas_limit, base_fee, calc.k_rate, calc.elasticity_multiplier) +next_base_fee = arc_calc_next_block_base_fee(smoothed_gas, gas_limit, base_fee, calc.k_rate, calc.inverse_elasticity_multiplier) # 3. Apply ProtocolConfig's own minBaseFee/maxBaseFee clamp (if available) if fee_params is not None: @@ -148,13 +148,8 @@ The assembler always writes `next_base_fee` from SystemAccounting to `extra_data ```python config = chainspec.base_fee_config(block_height) -# 1. Absolute bounds check +# Absolute bounds check assert config.absolute_min_base_fee <= header.base_fee <= config.absolute_max_base_fee - -# 2. Rate check (proportional, using max allowed k_rate) -if parent.number > 0: - max_delta = parent.base_fee * config.params_max.k_rate / 10000 - assert abs(header.base_fee - parent.base_fee) <= max_delta ``` **Stateful** (after block execution): diff --git a/docs/adr/0005-eip7702-authorization-list-recovery-bound.md b/docs/adr/0005-eip7702-authorization-list-recovery-bound.md new file mode 100644 index 00000000..5cca8f42 --- /dev/null +++ b/docs/adr/0005-eip7702-authorization-list-recovery-bound.md @@ -0,0 +1,50 @@ +# ADR-0005: EIP-7702 Authorization List Recovery Bound + +| Field | Value | +|---------------|----------------------| +| Status | Draft | +| Author(s) | @stelios-daveas | +| Created | 2026-07-06 | +| Updated | 2026-07-06 | +| Supersedes | - | +| Superseded by | - | + +## Context + +Arc's txpool validator (`ArcTransactionValidator`) recovers the authority address of every EIP-7702 authorization tuple in a submitted transaction (`recover_authority()`, in `check_for_denylisted_addresses`) so it can check each one against the address denylist. This recovery is ECDSA work, and it runs during mempool admission, before the sender's balance or fee is checked. + +Internal testing found that recovery cost scales with the number of authorizations in a transaction, with no upper bound of its own: `max_tx_input_bytes` constrains authorization count only indirectly, through an encoding-size assumption, and in practice let through far more authorizations than intended. Because recovery runs ahead of balance and fee checks, a transaction with a large authorization list could consume a disproportionate amount of validator CPU relative to the cost of submitting it. + +## Decision + +Add a direct, stateless cap on the authorization list's length: + +```rust +pub const MAX_AUTHORIZATIONS_PER_TX: usize = 100; +``` + +Checked immediately after Reth's own stateless validation and before any state access — ahead of blocklist/denylist SLOAD reads and, critically, ahead of authority recovery. Applied unconditionally, independent of whether the chain has a denylist. The rejection is classified `is_bad_transaction() = false` (`ArcTransactionValidatorError::TooManyAuthorizations`), since the limit is an Arc-local policy choice, not a cross-client protocol invariant — peers running other implementations may accept longer lists, so this must not affect peer scoring. + +100 was chosen to comfortably exceed realistic EIP-7702 usage (typically 1-3 delegations per transaction) while bounding worst-case per-transaction recovery cost to a small, fixed amount, regardless of encoding size, gas price, or account funding. + +## Consequences + +### Positive + +- Recovery cost per submitted transaction is bounded by a small constant, independent of transaction size, gas price, or sender funding — no economic modeling required. +- No new configuration surface; the cap is a hardcoded, always-on guard. + +### Negative + +- `100` is a heuristic sized against typical usage rather than derived from a formal RPC-ingress-rate or CPU budget. +- Balance and fee checks still run after denylist authority recovery; tightening that ordering further is a candidate for future follow-up. + +### Neutral + +- A legitimate transaction needing more than 100 delegations in a single transaction (no known Arc use case today) would be rejected outright. + +## Alternatives Considered + +- **Reorder balance/fee checks entirely ahead of denylist authority recovery.** A larger pipeline restructuring than this fix required; a candidate for future follow-up. +- **Per-sender or per-IP rate limiting at the RPC layer.** An infrastructure-level change outside the txpool validator's scope. +- **Require a minimum fee or stake before performing recovery.** Rejected — duplicates fee-cap logic that already exists later in the validation pipeline and complicates the stateless/stateful split. diff --git a/docs/adr/0006-proposer-timestamp-validation.md b/docs/adr/0006-proposer-timestamp-validation.md new file mode 100644 index 00000000..983e33e8 --- /dev/null +++ b/docs/adr/0006-proposer-timestamp-validation.md @@ -0,0 +1,243 @@ +# ADR-0006: Proposer Timestamp Validation + +| Field | Value | +| ------------- | ------------- | +| Status | Draft | +| Author(s) | @tomasz-kulik | +| Created | 2026-07-01 | +| Updated | 2026-08-25 | +| Supersedes | - | +| Superseded by | - | + +## Context + +Every Arc block header carries a timestamp. Consensus can only finalize a valid block and a valid block must carry a +valid timestamp. The block timestamp feeds EVM opcodes (`TIMESTAMP`), base-fee computation, and anything downstream that +relies on block time being monotonically non-decreasing. The question this ADR settles is _how a validator decides +whether a proposed block's timestamp is acceptable_, and why Arc makes that decision the way it does. + +### Lineage: BFT Time vs PBTS + +CometBFT, the main Tendermint implementation in Go, historically computed block timestamps using +[**BFT Time**](https://github.com/cometbft/cometbft/blob/main/spec/consensus/bft-time.md) — i.e., by computing the +median of the timestamps carried by the precommit votes used to finalize the previous block. CometBFT later replaced it +with +[**Proposer-Based Timestamps (PBTS)**](https://github.com/cometbft/cometbft/blob/main/spec/consensus/proposer-based-timestamp/README.md), +where the proposer assigns the timestamp of a block from its local time and each validator accepts it only if it is +_timely_ relative to the validator's own clock. PBTS's `timely` predicate is two-sided: a proposal is timely if it is +neither too far in the past nor too far in the future relative to when the validator received it, parameterized by two +synchronous parameters: `PRECISION` and `MSGDELAY`. + +## Decision + +Arc cannot adopt BFT Time because Precommit votes do not, and should not carry timestamps. Consensus today signs votes +with **Ed25519**, one signature per validator, and a commit certificate carries a `Vec` of those per-vote signatures +(`type SigningScheme = Ed25519` in `crates/types/src/context.rs`; `commit_signatures` in the certificate). Under that +scheme BFT Time is technically possible, but the vote path is deliberately architected for a **planned BLS +signature-aggregation fork** (see the currently-disabled `bls_commit_certificate` scaffolding in +`crates/types/src/spec.rs`). BLS aggregation requires every validator to sign the _same_ message, and a per-vote +timestamp would make each precommit a distinct message and defeat aggregation. So a weighted-median-of-vote-timestamps approach +is incompatible with the direction Arc's vote path is headed, which pushes Arc toward a proposer-assigned timestamp +validated against local clocks — the PBTS shape, not the BFT Time shape. + +As a result, the decision is to adopt a proposer-assigned block timestamp validated against each node's local clock, keeping only the **"too far in the +future"** half of PBTS's `timely` predicate with a fixed 30-second skew threshold. Nodes whose clocks drift beyond the +threshold are treated as byzantine rather than accommodated. + +The clock-skew judgment is a **vote-time-only** signal, evaluated in the consensus layer rather than in execution-layer +block admission. Because it reads the local wall clock it is non-deterministic across nodes, so it may only gate a node's +**prevote** — it must never become a block's persisted validity. A too-far-future proposal is prevoted nil, but the block +is still stored with its execution-only validity, so a value that nonetheless gathers a 2f+1 commit certificate is adopted +via sync with no permanent stall and no restart. + +Concretely: + +1. The proposer timestamps `max(parent_block.timestamp, now())` from its local clock. +2. A validator **prevotes nil** on a block iff `header.timestamp > local_time + 30s`. The block's persisted validity + (the execution verdict) is unaffected; only the vote is downgraded. +3. No lower-bound timeliness check is performed. +4. Clock synchronization (NTP) is an operational requirement for validators. A validator without an NTP-synchronized clock is considered byzantine. + +### Where the timestamp comes from + +The proposer timestamps a block from its own local clock, clamped to be non-decreasing relative to the parent: + +``` +timestamp = max(parent_block.timestamp, now()) +``` + +This guarantees monotonicity even if a proposer's clock briefly runs behind the parent block's time. + +### How a validator checks it + +A validating node compares the proposed timestamp against _its own_ local clock when it assembles a received proposal, +and **prevotes nil** if the timestamp is too far in the future. The threshold and predicate live in the consensus layer +(`crates/malachite-app/src/handlers/skew_gate.rs`); the clock-skew judgment is a consensus concern, so the execution +layer no longer references it. The consensus layer applies it when building the `ProposedValue` to vote on, without +touching the block's stored (execution-only) validity: + +```rust +// crates/malachite-app/src/handlers/skew_gate.rs +const ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS: u64 = 30; + +fn header_timestamp_exceeds_skew(header_timestamp: u64, local_time: u64) -> bool { + header_timestamp > local_time.saturating_add(ARC_PROPOSER_CLOCK_SKEW_THRESHOLD_SECS) +} + +// received_proposal_part.rs / started_round.rs +// An execution-Valid block whose timestamp exceeds the skew bound is prevoted +// Invalid (nil); the block is still stored with its execution-only validity. +``` + +Only the **upper** bound is enforced: a proposal may not be more than 30 seconds ahead of the validator's local clock. +There is no lower-bound ("too far in the past") check, because monotonicity already constrains that direction and a +stale timestamp carries no comparable risk. Arc also targets sub-second block times (~0.5s), so an honest proposer's +`now()` is never meaningfully behind the parent; a "too far in the past" bound would guard against a situation that does +not arise in practice. + +### Simplifications relative to PBTS + +| PBTS | Arc | +| --------------------------------------------------------- | ---------------------------------------------- | +| Two-sided `timely` predicate (past **and** future bounds) | Future bound only (`timestamp <= local + 30s`) | +| `PRECISION` + `MSGDELAY` parameters | Single 30s threshold | +| Tolerates bounded clock error as a first-class parameter | Treats out-of-sync nodes as byzantine | + +The _past_ bound is dropped because block timestamps are already non-decreasing by construction, so a proposal cannot +rewind time, and an unusually old (but monotonic) timestamp does not threaten safety or the fee/opcode semantics that +depend on block time. + +### Operational requirement + +Because validators judge timestamps against their own clocks, Arc assumes node operators keep clocks synchronized (e.g. +via NTP). A node whose clock drifts beyond the skew threshold is treated as byzantine for the purpose of timestamp +validation: it will disagree with correctly-synced peers and either reject valid proposals or accept invalid ones. This +is a deliberate trade — the protocol does not attempt to tolerate arbitrary clock drift; it requires operators to +prevent it. + +## Consequences + +### Positive + +- The proposer-timestamp design and its PBTS lineage are documented in-repo, discoverable and reviewable alongside the + consensus code that implements it. +- The clock-skew boundary case has an explicit, bounded impact analysis, so future readers do not need to rediscover why + it is tolerable. +- The upper-bound-only check is simple and stateless: a single comparison against the local clock, no PBTS parameters to + tune. +- Dropping the _past_ bound greatly simplifies the algorithm's adoption by Arc: the Tendermint algorithm (and thus Malachite Core) does not need to be modified, as it was for PBTS. + +### Negative + +- Correctness depends on operators keeping clocks synchronized; a widely drifted validator set degrades liveness. +- The fixed 30-second threshold is a network-wide constant rather than a tunable parameter; changing it requires a code + change. + +### Neutral + +- Dropping the lower-bound timeliness check diverges from CometBFT PBTS; the divergence is intentional and rests on + Arc's monotonic-timestamp construction. +- Treating out-of-sync nodes as byzantine shifts clock management from the protocol to node operations. +- Only a malicious or compromised proposer can boundary-time a proposal to waste its own turn. A correctly-configured + proposer never stamps a timestamp 30s in the future, so this is not a cost the design imposes on honest operation, and + the wasted slot is no worse than any other way a proposer can forfeit its turn (e.g. being briefly down). +- A byzantine proposer can still set the next block's timestamp up to 30 seconds in the future. This is also possible in PBTS. It is considered an acceptable risk. + +## Alternatives Considered + +**BFT Time (median of vote timestamps).** Rejected: incompatible with the planned BLS vote aggregation (see +[Decision](#decision) above), which requires all validators to sign an identical message. Per-vote +timestamps would defeat aggregation. + +**Full two-sided PBTS `timely` predicate.** Rejected as unnecessary: block timestamps are already non-decreasing, so the +"too far in the past" bound guards against a threat monotonicity already prevents. Keeping only the future bound reduces +the check to a single comparison. + +**Tolerating clock skew as a tuned parameter (PBTS `PRECISION`/`MSGDELAY`).** Rejected in favor of a single fixed +threshold plus an explicit operational NTP requirement. Treating drift as an operational failure is simpler than +modeling it in the protocol and matches Arc's assumption of professionally-operated validators. + +**Making the skew threshold a consensus parameter.** Rejected in favor of a compile-time constant. The threshold is +consensus-critical — every validator must apply the same value — so exposing it as a tunable parameter would mean +versioning and coordinating changes across the validator set (a governance/hardfork concern) for a value not expected to +need tuning. A fixed constant keeps the check trivial and uniform across the network. Promoting it to a +hardfork-gated or governance-controlled parameter remains open if operational experience later shows the 30-second bound +must vary. + +**Gating the precommit/commit step on timeliness.** Rejected: making an already-polka'd value's precommit or final +decision conditional on a fresh timeliness check would render a decided value retroactively rejectable on timing +grounds, converting the bounded liveness cost into a potential safety hazard. Keeping timeliness a prevote-time gate — +combined with the upper-bound-only check, so the re-validation that does run at each round start can never reject an +already-timely value — is what keeps the blast radius contained. + +## Appendix: Security Considerations + +A security review surfaced an edge case the original design did not spell out. Because each honest validator checks the +proposal against its _own_ clock, two honest validators with slightly different clocks can reach **opposite** verdicts +on the same block when its timestamp sits right at the 30-second boundary. + +### Mechanism + +Suppose a proposer chooses a timestamp `T`. For a validator with local time `L`, the block is accepted iff +`T <= L + 30`. Two honest validators `A` and `B` with `L_A < L_B` (A's clock lags B's) can straddle the boundary: + +- `T <= L_B + 30` → **B accepts** (prevotes the block). +- `T > L_A + 30` → **A rejects** (prevotes nil). + +An honest proposer stamps `T ≈ now()`, comfortably inside 30s for every correctly-synced validator, so this split does +not arise in normal operation. Only a **malicious or compromised proposer** can deliberately pick a `T` near the +boundary to maximize the set of validators that reject while others accept. + +### Impact and classification + +This is a **liveness** concern, not a **safety** one: + +- **Worst case:** the proposer engineers a split that leaves more than 1/3 of voting power prevoting nil. No value + reaches a polka in that round, the round fails, and the next proposer (by round-robin) drives a new round to a + decision. The net effect is one proposer's turn being wasted — comparable to a validator being briefly down for + maintenance (roughly 1-in-16 heights for a single faulty proposer in the expected validator set), which the protocol + already tolerates. +- **Safety is never at risk.** Two honest validators can never _commit_ different values as a result of a timestamp + split, because timeliness gates only whether a value can be prevoted toward a polka. It does not gate the final + decision. + +### Why the blast radius is bounded + +The timeliness check runs on the consensus paths that build a node's **prevote** — both the live-arrival path +(`crates/malachite-app/src/handlers/received_proposal_part.rs`) and the buffered/early-arrival path at round start +(`crates/malachite-app/src/handlers/started_round.rs`, for parts that arrived before their round began) — where it +downgrades **only the prevote**. It is not consulted at the precommit or commit step, it never touches a block's +persisted validity, and it does not run on the value-sync adoption path (which validates execution-only). Two properties +keep it from ever retroactively rejecting a value: + +1. An untimely proposal can fail to polka in the proposer's own round, but it cannot stall the height beyond that round: + the next proposer is free to propose a timely value and carry the height to a decision. +2. The clock verdict never becomes a block's persisted validity: a too-far-future block is stored execution-`Valid`, so a + value that gathers a commit certificate is adopted via sync regardless of any node's clock. A prevote-time nil vote is + the only effect, so no already-decided value is retroactively invalidated and no node stalls waiting on its clock. + +Keeping the clock verdict out of persisted validity (rather than relying only on the upper-bound property to make +re-validation safe) is what lets Arc adopt this without changes to the Tendermint algorithm. +In short: a malicious proposer can burn its own turn, and no more. + +### Validation timing, sync, and restart + +`local_time` is the validating node's wall clock (`SystemTime::now()`), read afresh each time a proposal's prevote is +built — whether the parts arrive live or were buffered and re-offered at round start — not the block's arrival time nor +a value cached from an earlier check. The clock verdict gates only that node's prevote; it is never written to the +block's persisted validity, and because the bound is upper-only it can only relax as the wall clock advances, so a value +that was timely when first seen stays timely on every later prevote — provided the local clock does not step backwards. +If it does (e.g. an NTP correction across a restart), the node may prevote nil on a block it previously accepted; the +block is still adopted once it carries a commit certificate, because the verdict is never persisted (point 2 above). + +Blocks obtained through value sync are already-decided blocks pulled from a peer. The value-sync adoption path applies +execution validation only — no clock check — so a decided block is adopted regardless of how far a node's local clock +lags its timestamp. Round-start re-validation (including after a restart) does apply the clock check when it rebuilds a +node's prevote, but only to that vote: it never touches persisted validity, so it cannot block adoption of a value that +reaches a commit certificate. It does mean the node cannot decide on that certificate directly — adoption goes around +through sync — so a persistently skewed validator degrades to a sync-follower rather than stalling, at one sync +round-trip per height. Previously the check ran at execution-layer admission (`engine_newPayload`), where a lagging +clock produced a hard `Invalid` that +Reth cached in its in-memory invalid-headers table and that was persisted as the block's validity — permanently stalling +the node at that height until a restart cleared the cache. Moving the check to a vote-time-only signal removes that +stall: no decided block is unsyncable on timing grounds, and the invalid-headers cache is never poisoned. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9e840d27..6327f798 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -41,3 +41,4 @@ If a decision affects multiple components or has lasting implications, it likely | [0002](0002-block-dissemination-protocol.md) | Block Dissemination Protocol | Draft | 2026-01-13 | | [0003](0003-governance-configuration-and-validation.md) | Dynamic Block Gas Limit Configuration Validation | Draft | 2026-02-20 | | [0004](0004-base-fee-validation.md) | Base Fee Parameter Validation | Draft | 2026-03-03 | +| [0005](0005-eip7702-authorization-list-recovery-bound.md) | EIP-7702 Authorization List Recovery Bound | Draft | 2026-07-06 | diff --git a/docs/running-an-arc-node.md b/docs/running-an-arc-node.md index 93f04f9d..58e7e5b3 100644 --- a/docs/running-an-arc-node.md +++ b/docs/running-an-arc-node.md @@ -103,24 +103,49 @@ A **snapshot** is needed to bootstrap the node: ```sh arc-snapshots download \ --chain=arc-testnet \ + --el-profile=full \ --execution-path "$ARC_EXECUTION" \ --consensus-path "$ARC_CONSENSUS" ``` -The `arc-snapshots` binary is part of the Arc node installation. -The command above fetches the latest snapshots for `arc-testnet` chain from -https://snapshots.arc.network and extracts them into the -`$ARC_CONSENSUS` and `$ARC_EXECUTION` data directories. -The command is safe to rerun; existing snapshot data is detected unless you -pass the command's `--force` option. - -> **Download sizes:** At the time of writing, the most recent snapshot sizes -> (tagged `20260408`) are: **~68 GB** for EL and **~16 GB** for CL. -> These are the sizes of the downloaded compressed snapshots; when extracted, -> the sizes are ~103 GB for EL and ~36 GB for CL. -> -> On a fast connection (~100 Mbps) the download takes roughly 10-15 minutes; -> on slower or metered connections it can take hours. +Published snapshots use reth's storage v2 format. Rather than shipping the +execution layer as one compressed archive, it publishes a manifest listing each +database component on its own, which is what lets `--el-profile` fetch part of a +snapshot instead of all of it. The consensus layer is still a single `.tar.lz4` +archive. Everything below simply calls these snapshots. + +The `arc-snapshots` binary is part of the Arc node installation. It queries +https://snapshots.arc.network for the newest snapshot that has both layers +published, whatever its retention, and restores them into `$ARC_EXECUTION` and +`$ARC_CONSENSUS`. The execution artifact is a reth manifest, which +`arc-snapshots` restores by invoking `arc-node-execution` (also part of the +installation), which must be on `PATH` or named by `ARC_EXECUTION_BINARY`. + +`--el-profile` chooses how much execution-layer history to fetch: `minimal`, +`full`, or `archive`. It defaults to `minimal`, including for an explicit +manifest URL. The example passes `full` because the execution layer below starts +with `--full`. An archive node must pass `--el-profile=archive`; omitting the +flag produces a minimal restore. An explicit `.tar.lz4` execution URL still +selects the native archive restore and ignores this flag. + +Automatically resolved manifest URLs carry no query string, and they must not. +Reth derives each component's URL by dropping the manifest's filename and +appending the component's as plain string concatenation, so a signed URL keeps +its query through that step and the component filename lands on the end of the +query string instead of the path. Every component is then fetched from an +address that does not exist. Do not pass a presigned manifest URL by hand. + +A rerun without `--force` leaves a layer alone if it already holds the requested +snapshot. If it resolves a *newer* snapshot, the layer is replaced rather than +merged into, so expect a full download. And if a layer holds data that no restore +recorded — a node synced from genesis, or an earlier restore that did not finish — +the command stops and asks for `--force` rather than guessing which it is. +`--force` replaces both layers regardless of what they hold. See +[`crates/snapshots/README.md`](../crates/snapshots/README.md#restore-behavior) +for the exact rules. + +> **Download sizes:** At the moment of writing the combined execution and consensus +layers uncompressed data is about 250GB. ### Initialize consensus layer @@ -143,18 +168,12 @@ arc-node-execution node \ --auth-ipc --auth-ipc.path $ARC_RUN/auth.ipc \ --http --http.addr 127.0.0.1 --http.port 8545 \ --http.api eth,net,web3,txpool,trace,debug \ - --rpc.forwarder https://rpc.quicknode.testnet.arc.network/ \ + --rpc.forwarder https://rpc.testnet.arc.io/ \ --metrics 127.0.0.1:9001 \ --disable-discovery \ --enable-arc-rpc ``` -> **Note on `--full` and snapshots:** The `--full` flag is required on the -> first start when bootstrapping from a pruned snapshot. It reconciles internal -> database tables that would otherwise fail a consistency check. After the -> initial startup completes, you may restart without `--full` if you prefer to -> run without pruning. - The `--chain` parameter configures the genesis file. By using `--chain arc-testnet`, the genesis configuration bundled in the binary is adopted. Replace with `--chain /path/to/genesis.json` if you have a custom genesis file. @@ -189,9 +208,9 @@ arc-node-consensus start \ --execution-socket $ARC_RUN/auth.ipc \ --rpc.addr 127.0.0.1:31000 \ --follow \ - --follow.endpoint https://rpc.drpc.testnet.arc.network,wss=rpc.drpc.testnet.arc.network \ - --follow.endpoint https://rpc.quicknode.testnet.arc.network,wss=rpc.quicknode.testnet.arc.network \ - --follow.endpoint https://rpc.blockdaemon.testnet.arc.network,wss=rpc.blockdaemon.testnet.arc.network/websocket \ + --follow.endpoint https://rpc.testnet.arc.io,wss=rpc.testnet.arc.io \ + --follow.endpoint https://rpc.drpc.testnet.arc.io,wss=rpc.drpc.testnet.arc.io \ + --follow.endpoint https://rpc.blockdaemon.testnet.arc.io,wss=rpc.blockdaemon.testnet.arc.io/websocket \ --execution-persistence-backpressure \ --execution-persistence-backpressure-threshold=50 \ --metrics 127.0.0.1:29000 @@ -297,13 +316,42 @@ docker compose up -d On the first run, init containers automatically: -1. Download the latest testnet snapshots (~84 GB compressed — see - [download sizes](#download-snapshots) for details) +1. Download the latest complete testnet snapshot pair. See + [download sizes](#download-snapshots) for a measured minimal restore. 2. Initialize the consensus layer private key 3. Prepare the shared IPC socket volume -Subsequent runs detect that initialization is already complete and start -immediately. +The init containers run again on every `docker compose up`. Normally they finish in +seconds, because each layer already holds the snapshot it was given. Two situations +are exceptions. + +**A newer snapshot has been published.** With no URLs in the command, the +service asks the API for the latest snapshot on every run, and a newer one +usually exists within hours. It restores that one instead, which costs a second +full download and moves the node back to the snapshot's block, discarding +whatever it had synced since. + +**A previous restore did not finish.** A layer is marked as restored only after +its download and extraction have both finished, so an interrupted run leaves +data behind with no mark on it. On the next run the service cannot tell that +half-written snapshot apart from a directory you filled yourself, by syncing +from genesis or by running a validator, so it stops with an error rather than +delete someone else's data. Nothing else starts either, because the rest of the +stack waits on this container. Add `--force` to let it overwrite the layer, +bring the stack up once, then take the flag out again: + +```yaml + command: + - download + - --chain=arc-testnet + - --el-profile=minimal + - --force + - --execution-path=/data/execution + - --consensus-path=/data/consensus +``` + +`FORCE_SNAPSHOT_RESTORE=true` has no effect on this stack. `arc-snapshots` does not +read it; `--force` is how a clean restore is requested. > The init container runs as root so it can set file ownership for the > main services (UID 999). No manual `chown` is needed. @@ -355,6 +403,13 @@ rm -rf ~/.arc ## Separated hosts +> [!WARNING] +> Running EL and CL on separate hosts requires the RPC/HTTP Engine API transport, +> which will be deprecated in `v0.8.0` and will be removed in `v0.9.0`. +> Run both layers on the same host and use IPC instead (see the +> [Binaries](#binaries) section). +> The consensus layer logs a startup warning when any RPC option is set. + The [Binaries](#binaries) section describes the setup of the execution (EL) and consensus (CL) layers running in the same host. The two processes interact via Inter-Process Communication (IPC), @@ -377,21 +432,39 @@ Generate it in one host and securely copy it into the other host. ### Execution layer -From the [Start execution layer](#start-execution-layer) instructions, two changes are required: +From the [Start execution layer](#start-execution-layer) instructions, three +changes are required: -1. Remove all flags related to IPC communication: `--ipcpath`, `--auth-ipc`, `--auth-ipc.path`; -2. Add the following parameters to configure the RPC interaction: +1. Remove all flags related to IPC communication: `--ipcpath`, `--auth-ipc`, + `--auth-ipc.path`; +2. Rebind the eth JSON-RPC off loopback so the consensus layer's host can reach + it: change `--http.addr 127.0.0.1` to an interface the consensus layer can + reach. The consensus layer runs a startup connectivity check against this endpoint + and will not start if it is unreachable. +3. Add the following parameters to configure the authenticated Engine API: ```sh --authrpc.addr 0.0.0.0 \ --authrpc.port 8551 \ --authrpc.jwtsecret "$ARC_HOME/jwtsecret" ``` -**Important:** with this setup, port 8551 is exposed via all network -interfaces (`0.0.0.0`). -Make sure to configure the firewall to restrict the access to this port to the -consensus layer's host. -The Engine API controls block production — do not expose it to the public internet. +If the consensus layer runs persistence backpressure — the base +[Start consensus layer](#start-consensus-layer) example enables it — the +execution layer must also run a WebSocket server exposing the `reth` namespace, +or backpressure stays inactive (the consensus layer still starts and retries the +connection in the background). The consensus layer derives the WebSocket address +from `--eth-rpc-endpoint` (http→ws, port + 1), i.e. port `8546`: + +```sh + --ws --ws.addr 0.0.0.0 --ws.port 8546 --ws.api reth +``` + +> [!IMPORTANT] +> With this setup, ports 8545 (eth JSON-RPC), 8546 (WebSocket, when +> backpressure is enabled), and 8551 (Engine API) are exposed on all network +> interfaces (`0.0.0.0`). Configure the firewall to restrict access to these ports +> to the consensus layer's host. The Engine API (8551) controls block production — +> **never** expose it to the public internet. ### Consensus layer @@ -697,19 +770,40 @@ Check out [reth system requirements](https://reth.rs/run/system-requirements/) f **Note**: during periods of sustained high load, such as during startup or extended sync if the node is far behind, the execution layer memory may surge on some hardware. This should not be an issue if running with the suggested System Requirements. However, if you do observe this, you can enable backpressure to throttle the pace of execution according to the speed of disk writes, which will constrain memory growth. -To enable this, the `reth_` namespace should enabled on the **execution layer**: - -```sh ---http.api eth,net,web3,txpool,trace,debug,reth -``` - -And on the **consensus layer** backpressure must be activated: +Backpressure works by having the consensus layer subscribe to the execution +layer's `reth_subscribePersistedBlock` notification and pause block replay until +the execution layer's persisted height catches up. +Activate it on the consensus layer: ```sh --execution-persistence-backpressure \ --execution-persistence-backpressure-threshold=10 ``` +No `--http.api` change is needed on the execution layer. +How the notification reaches the consensus layer depends on the transport: + +- **IPC (recommended):** automatic. Reth serves the `reth` namespace on the + `--ipcpath` socket by default, so the [primary setup](#start-execution-layer) + above works as-is. +- **HTTP/RPC transport (deprecated):** the notification is a subscription, + which plain HTTP JSON-RPC cannot carry. The execution layer must also run a + WebSocket server that exposes the `reth` namespace: + + ```sh + --ws --ws.addr 127.0.0.1 --ws.port 8546 --ws.api reth + ``` + + The consensus layer derives the WebSocket URL from `--eth-rpc-endpoint` + (http→ws, port + 1), or takes it from `--execution-ws-endpoint`. + Bind `--ws.addr` to an interface the consensus layer can reach: `127.0.0.1` works + only when both layers share a host. + On [separated hosts](#separated-hosts) use a reachable interface and firewall + port 8546 to the consensus layer's host. + If the consensus layer cannot reach the WebSocket server it still starts — + backpressure just never engages. + Adding `reth` to `--http.api` does **not** enable the subscription. + Note: arc-node is alpha software and this performance issue is actively being worked on. ### Production Deployment @@ -748,7 +842,7 @@ ExecStart=/usr/local/bin/arc-node-execution node \ --http.api eth,net,web3,txpool,trace,debug \ --metrics 127.0.0.1:9001 \ --enable-arc-rpc \ - --rpc.forwarder https://rpc.quicknode.testnet.arc.network/ + --rpc.forwarder https://rpc.testnet.arc.io/ Restart=always RestartSec=10 @@ -786,9 +880,9 @@ ExecStart=/usr/local/bin/arc-node-consensus start \ --execution-socket /run/arc/auth.ipc \ --rpc.addr 127.0.0.1:31000 \ --follow \ - --follow.endpoint https://rpc.drpc.testnet.arc.network,wss=rpc.drpc.testnet.arc.network \ - --follow.endpoint https://rpc.quicknode.testnet.arc.network,wss=rpc.quicknode.testnet.arc.network \ - --follow.endpoint https://rpc.blockdaemon.testnet.arc.network,wss=rpc.blockdaemon.testnet.arc.network/websocket \ + --follow.endpoint https://rpc.testnet.arc.io,wss=rpc.testnet.arc.io \ + --follow.endpoint https://rpc.drpc.testnet.arc.io,wss=rpc.drpc.testnet.arc.io \ + --follow.endpoint https://rpc.blockdaemon.testnet.arc.io,wss=rpc.blockdaemon.testnet.arc.io/websocket \ --execution-persistence-backpressure \ --execution-persistence-backpressure-threshold=50 \ --metrics 127.0.0.1:29000 @@ -846,13 +940,41 @@ For production monitoring, scrape the Prometheus metrics endpoints with Grafana: ### Pruning -The `--full` flag is accepted by both the CL and EL and will enable pruning. -When bootstrapping from a pruned snapshot, `--full` is **required** on the -first EL start to reconcile the database (see the note in -[Start execution layer](#start-execution-layer)). After that initial run you -can restart without `--full`. +The `--full` and `--minimal` flags are accepted by both the CL and EL and will enable pruning. > **Caution:** EL pruning increases memory usage and may cause out-of-memory > issues on constrained machines. If you encounter memory pressure, enable > backpressure (see [System Requirements](#system-requirements) section) and remove > `--full` after the first successful start. + +`--full` and `--minimal` are the execution layer's two pruning presets: +`--full` retains more history (the last 237,600 blocks for most data), +`--minimal` far less (for example, 64 blocks of receipts); running with neither +keeps everything (archive). Run `arc-node-execution --help`, or see the +[execution binary reference](../crates/node/README.md), for the exact +per-preset retention. Published snapshots are in `archive` format (i.e., not +pruned), while `--el-profile` decides how much execution data to restore. Use +the profile corresponding to how the node will run: `minimal` with `--minimal`, +`full` with `--full`, or `archive` with neither preset. Starting `--minimal` +against a datadir restored with `--el-profile=full` or `--el-profile=archive` +still needs the offline procedure below: + +> [!IMPORTANT] +> **Switching to `--minimal` after a snapshot restore.** Starting directly +> with `--minimal` against a datadir restored from a `--full` or archive +> snapshot makes the online pruner delete the whole difference between the two +> presets while racing to tip — the node can get stuck in sync. Prune offline +> first: +> +> 1. Stop `arc-node-execution` and `arc-node-consensus`. +> 2. Delete `$ARC_EXECUTION/reth.toml`. +> 3. Briefly start the EL with `--minimal` (plus your steady-state flags) +> until the `Saving prune config to toml file` log line, then stop it. The +> `prune` subcommand reads its target profile from `reth.toml`, not from +> `--minimal`, so this step is what writes it. +> 4. Confirm `$ARC_EXECUTION/reth.toml` contains `prune.profile = "minimal"`. +> 5. Run `arc-node-execution prune --datadir $ARC_EXECUTION`. +> 6. Start both layers with `--minimal`. +> +> To change preset later (`--minimal` ↔ `--full`, or to/from archive), delete +> `reth.toml` and restart with the preset flag you want. diff --git a/docs/tx-forwarding.md b/docs/tx-forwarding.md new file mode 100644 index 00000000..2f1af027 --- /dev/null +++ b/docs/tx-forwarding.md @@ -0,0 +1,122 @@ +# Transaction forwarding and failover for follow nodes + +Follow nodes (RPC read nodes) do not build blocks. They serve reads locally and +forward write transactions to an upstream that does build blocks. + +Key points that hold for every method below: + +- Only raw transaction submission is forwarded, `eth_sendRawTransaction` and + `eth_sendRawTransactionSync`. Every other method is served from the follow + node's local state. +- Each accepted transaction is retained in the local pool, so local RPC reads + see it before it is mined and the block is imported. +- A single upstream is a single point of failure: submission breaks whenever + that endpoint is down. + +| Method | Failover | Health checks | Load balancing | Extra infra | +|--------|----------|---------------|----------------|-------------| +| `--rpc.forwarder` (single upstream) | No | No | No | None | +| `--rpc.forwarder` (external proxy) | Yes, your policy | Active | Yes | A proxy to operate | +| `--arc.tx.relays` (built in) | Yes, ordered | Per request | No | None | + +## Method 1: single upstream + +When one upstream is enough, Reth's built-in `--rpc.forwarder` forwards raw +transaction submission to a single URL with no failover. + +```sh +arc-node-execution node \ + --chain arc-testnet \ + --datadir $ARC_EXECUTION \ + --http --http.addr 127.0.0.1 --http.port 8545 \ + --http.api eth,net,web3 \ + --rpc.forwarder https://rpc-a.example/ +``` + +This is the simplest option and matches the setup in +[Running an Arc node](./running-an-arc-node.md). It has no redundancy: if the +upstream is unreachable, submission fails. + +## Method 2: external proxy + +If you already operate a layer-7 proxy such as nginx, HAProxy, or Envoy, point +`--rpc.forwarder` at it and let the proxy select upstreams. The proxy owns the +forwarding policy, so you can configure active health checks, weighting, load +balancing, and TLS termination however you want. + +The proxy must listen on a different address than the node's own RPC port, +otherwise the forwarder loops back to the node. Here the node serves on 8545 and +the proxy on 8600: + +```sh +arc-node-execution node \ + --chain arc-testnet \ + --datadir $ARC_EXECUTION \ + --http --http.addr 127.0.0.1 --http.port 8545 \ + --http.api eth,net,web3 \ + --rpc.forwarder http://127.0.0.1:8600/ +``` + +Example nginx upstream with an ordered failover, primary plus backup: + +```nginx +upstream arc_rpc { + server rpc-a.example:443 max_fails=3 fail_timeout=10s; + server rpc-b.example:443 backup; +} + +server { + listen 8600; + location / { + proxy_pass https://arc_rpc; + proxy_next_upstream error timeout http_502 http_503 http_504 http_429; + proxy_connect_timeout 5s; + } +} +``` + +Choose this when you already run such a proxy, or need active health probing or +weighted load balancing. The trade-off is another component to deploy and operate. + +## Method 3: built-in multi-relay failover + +`--arc.tx.relays` takes a comma-separated list of upstream URLs in priority order +and fails over across them with no additional infrastructure. + +```sh +arc-node-execution node \ + --chain arc-testnet \ + --datadir $ARC_EXECUTION \ + --http --http.addr 127.0.0.1 --http.port 8545 \ + --http.api eth,net,web3 \ + --arc.tx.relays https://rpc-a.example/,https://rpc-b.example/,https://rpc-c.example/ +``` + +The list may also be supplied through the `ARC_TX_RELAYS` environment variable. +`--arc.tx.relays.timeout` (env `ARC_TX_RELAYS_TIMEOUT`, default `10s`) bounds each +relay attempt, connection plus response; when it elapses the relay advances to +the next upstream. It accepts `10s`, `500ms`, or a bare number of seconds. The +default suits Arc's sub-second finality; raise it if `eth_sendRawTransactionSync` +submissions need longer to be mined. + +Behavior: + +- Relays `eth_sendRawTransaction` and `eth_sendRawTransactionSync` to the current + upstream, sticky to the last good one. +- Advances to the next upstream, wrapping past the end of the list, on a + transport failure (connection refused, DNS or TLS error, timeout) or an HTTP + 5xx or 429 response. One full pass is attempted before giving up. +- Returns a JSON-RPC error from a reachable upstream verbatim, without failover. + A rejected transaction is a decision, not an outage. +- Retains each accepted transaction in the local pool so local RPC reads see it + before it is mined. +- Returns a relay error when every upstream fails a full pass, and does not add + the transaction locally. + +Conflicts with `--rpc.forwarder`; set one or the other, not both. + +Metrics: + +- `arc_tx_relay_failovers_total` increments on each advance to the next upstream. +- `arc_tx_relay_exhausted_total` increments when a full pass finds no reachable + upstream. diff --git a/package-lock.json b/package-lock.json index a1f5e847..46a27481 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2488,16 +2488,16 @@ } }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/balanced-match": { @@ -4065,9 +4065,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -6485,12 +6485,15 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, "license": "MIT", - "peer": true + "peer": true, + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -10036,15 +10039,15 @@ } }, "axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, "peer": true, "requires": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "balanced-match": { @@ -11172,9 +11175,9 @@ "dev": true }, "follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true }, "for-each": { @@ -11653,7 +11656,7 @@ "@ethersproject/bytes": "^5.7.0", "@ethersproject/units": "^5.7.0", "@solidity-parser/parser": "^0.20.1", - "axios": "^1.6.7", + "axios": "1.16.0", "brotli-wasm": "^2.0.1", "chalk": "4.1.2", "cli-table3": "^0.6.3", @@ -12860,9 +12863,9 @@ } }, "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, "peer": true }, diff --git a/package.json b/package.json index c74e4a94..67a58d6d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "license": "ISC", "description": "", "overrides": { + "axios": "1.16.0", "bn.js@<4.12.3": "4.12.3", "cookie": "0.7.0", "flatted": "3.4.2", diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 47a730be..fb5449af 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.91.1" +channel = "1.93.0" components = ["clippy", "rustfmt"] diff --git a/scripts/compare_saturation.py b/scripts/compare_saturation.py new file mode 100644 index 00000000..bd27e00b --- /dev/null +++ b/scripts/compare_saturation.py @@ -0,0 +1,740 @@ +#!/usr/bin/env python3 +"""Compare two saturation experiments and produce a self-contained HTML report. + +Dependencies: matplotlib, jinja2 (pip install matplotlib jinja2). +""" + +import base64 +import io +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt +except ImportError: + sys.exit("matplotlib is required: pip install matplotlib") + +try: + from jinja2 import Environment + from markupsafe import Markup, escape +except ImportError: + print("error: jinja2 is required — install with: pip3 install jinja2", file=sys.stderr) + sys.exit(1) + +# ── colours ────────────────────────────────────────────────────────────────── +C_A = "#4C72B0" # reth1 (blue) +C_B = "#DD8452" # reth2 (orange) +C_GRID = "#e0e0e0" + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _fig_to_b64(fig) -> str: + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") + plt.close(fig) + buf.seek(0) + return base64.b64encode(buf.read()).decode() + + +def _label(image: Optional[str]) -> str: + if not image: + return "unknown" + tag = image.split(":")[-1] + return tag + + +def _short(image: Optional[str]) -> str: + if not image: + return "unknown" + return image.split("/")[-1] # e.g. arc-execution:reth2 + + +def _leading_alpha_prefix(name: str) -> str: + chars = [] + for c in name: + if c.isalpha(): + chars.append(c) + else: + break + return "".join(chars) if chars else name + + +def _topology_from_manifest(manifest: dict) -> dict: + """Reconstruct the legacy `Topology` summary fields from an embedded manifest. + + The saturation runner now embeds the full parsed manifest under + `parameters.manifest` instead of a hand-picked Topology struct. Mirrors + `_topology_from_manifest` in `saturation_report.py` so both scripts share + the same fallback behavior. + """ + nodes = manifest.get("nodes") or {} + node_names = list(nodes.keys()) + + nodes_by_type: dict[str, int] = {} + for name in node_names: + t = _leading_alpha_prefix(name) + nodes_by_type[t] = nodes_by_type.get(t, 0) + 1 + nodes_by_type = dict(sorted(nodes_by_type.items())) + + num_validators = sum(1 for n in node_names if n.startswith("validator")) + + el_storage_v2 = True + if node_names: + first = nodes[node_names[0]] + v2 = (first.get("el") or {}).get("config", {}).get("storage", {}).get("v2") + if v2 is not None: + el_storage_v2 = bool(v2) + + return { + "num_nodes": len(node_names), + "num_validators": num_validators, + "nodes_by_type": nodes_by_type, + "node_size": manifest.get("node_size"), + "node_disk_gb": manifest.get("node_disk_gb"), + "node_volume_type": manifest.get("node_volume_type"), + "node_volume_iops": manifest.get("node_volume_iops"), + "el_cpu_limit": manifest.get("el_cpu_limit"), + "el_memory_limit_gb": manifest.get("el_memory_limit_gb"), + "cl_cpu_limit": manifest.get("cl_cpu_limit"), + "cl_memory_limit_gb": manifest.get("cl_memory_limit_gb"), + "extra_account_balance_usdc": manifest.get("extra_account_balance_usdc"), + "block_gas_limit": manifest.get("block_gas_limit"), + "cc_size": manifest.get("cc_size"), + "cc_disk_gb": manifest.get("cc_disk_gb"), + "image_el": manifest.get("image_el"), + "image_cl": manifest.get("image_cl"), + "el_storage_v2": el_storage_v2, + } + + +def _fmt_node_disk(topology: dict) -> str: + """` GiB / / IOPS`, skipping unset components.""" + size = topology.get("node_disk_gb") + vol_type = topology.get("node_volume_type") + iops = topology.get("node_volume_iops") + parts = [] + if size is not None: + parts.append(f"{size} GiB") + if vol_type: + parts.append(vol_type) + if iops is not None: + parts.append(f"{iops} IOPS") + return " / ".join(parts) if parts else "—" + + +def _fmt_container_resources(cpu, mem_gb) -> str: + """` CPU / GiB`, skipping unset components.""" + parts = [] + if cpu is not None: + parts.append(f"{cpu:g} CPU") + if mem_gb is not None: + parts.append(f"{mem_gb:g} GiB") + return " / ".join(parts) if parts else "—" + + +def _fmt_usdc(v) -> str: + return f"{v:,} USDC" if v is not None else "—" + + +def _fmt_node_types(by_type: dict) -> str: + """`13 sentry, 36 full, 21 validator` — sorted by count descending.""" + if not by_type: + return "—" + items = sorted(by_type.items(), key=lambda kv: (-kv[1], kv[0])) + return ", ".join(f"{count} {name}" for name, count in items) + + +def _fmt(v, spec=".0f", suffix=""): + return f"{v:{spec}}{suffix}" if v is not None else "—" + + +def _fmt_avg_sd(avg, sd): + if avg is None: + return "—" + if sd is None: + return f"{avg:.0f} ms" + return f"{avg:.0f}±{sd:.0f} ms" + + +def _fmt_count_mb(count, size_mb): + """Render a sub-pool depth cell as ``count (MB)``. + + Reth caps each sub-pool on count and size in MB independently; show both + so the reader can see which dimension is the binding constraint. Either + half renders as ``—`` when the experiment.json lacks the field (e.g. older + runs without the size_bytes Prometheus query). + """ + if count is None and size_mb is None: + return "—" + c = f"{count:.0f}" if count is not None else "—" + s = f"{size_mb:.1f}" if size_mb is not None else "—" + return f"{c} ({s})" + + +def _fmt_gas(v): + if v is None: + return "—" + if v >= 1e6: + return f"{v/1e6:.1f}M" + if v >= 1e3: + return f"{v/1e3:.1f}K" + return f"{v:.0f}" + + +SIGNAL_LABELS = { + "gas_plateaued": "Gas Plateaued", + "tps_plateaued": "TPS Plateaued", + "tps_ratio_drop": "TPS Ratio Drop", + "latency_spike": "Latency Spike", + "mempool_growth": "Mempool Growth", + "el_cpu_saturated": "EL CPU Saturated", +} + +# ── chart helpers ───────────────────────────────────────────────────────────── + +def _xs(n): + return list(range(n)) + + +def _bar_pair(ax, xs, vals_a, vals_b, label_a, label_b, width=0.35): + xs_a = [x - width/2 for x in xs] + xs_b = [x + width/2 for x in xs] + ax.bar(xs_a, vals_a, width, color=C_A, alpha=0.85, label=label_a) + ax.bar(xs_b, vals_b, width, color=C_B, alpha=0.85, label=label_b) + + +def _line_pair(ax, xs, vals_a, vals_b, label_a, label_b, marker="o"): + ax.plot(xs, vals_a, color=C_A, marker=marker, linewidth=2, label=label_a) + ax.plot(xs, vals_b, color=C_B, marker=marker, linewidth=2, label=label_b) + + +def _finish(ax, fig, x_labels, title, ylabel, legend=True): + ax.set_xticks(_xs(len(x_labels))) + ax.set_xticklabels(x_labels) + ax.set_xlabel("Offered TPS") + ax.set_ylabel(ylabel) + ax.set_title(title) + ax.grid(axis="y", color=C_GRID) + if legend: + ax.legend(fontsize=8) + fig.tight_layout() + + +# ── individual charts ───────────────────────────────────────────────────────── + +def chart_throughput(phases_a, phases_b, labels, la, lb) -> str: + xs = _xs(len(phases_a)) + offered = [p["offered_tps"] for p in phases_a] + tps_a = [p["metrics"].get("actual_tps") or 0 for p in phases_a] + tps_b = [p["metrics"].get("actual_tps") or 0 for p in phases_b] + + fig, ax = plt.subplots(figsize=(8, 3.5)) + _bar_pair(ax, xs, tps_a, tps_b, la, lb) + ax.plot(xs, offered, color="black", linestyle="--", linewidth=1.2, + label="Offered TPS", zorder=5) + _finish(ax, fig, labels, "Actual TPS vs Offered", "TPS") + return _fig_to_b64(fig) + + +def chart_gas(phases_a, phases_b, labels, la, lb) -> str: + xs = _xs(len(phases_a)) + gas_a = [p["metrics"].get("gas_per_sec") or 0 for p in phases_a] + gas_b = [p["metrics"].get("gas_per_sec") or 0 for p in phases_b] + + fig, ax = plt.subplots(figsize=(8, 3.5)) + _bar_pair(ax, xs, [g/1e6 for g in gas_a], [g/1e6 for g in gas_b], la, lb) + _finish(ax, fig, labels, "Gas Throughput", "Gas/s (M)") + return _fig_to_b64(fig) + + +def chart_fill(phases_a, phases_b, labels, la, lb) -> str: + xs = _xs(len(phases_a)) + fill_a = [p["metrics"].get("fill_pct") or 0 for p in phases_a] + fill_b = [p["metrics"].get("fill_pct") or 0 for p in phases_b] + + fig, ax = plt.subplots(figsize=(8, 3.5)) + _bar_pair(ax, xs, fill_a, fill_b, la, lb) + ax.axhline(100, color="red", linestyle="--", linewidth=1, alpha=0.6, label="100% (full blocks)") + _finish(ax, fig, labels, "Block Fill %", "Fill %") + return _fig_to_b64(fig) + + +def chart_latency(phases_a, phases_b, labels, la, lb) -> str: + xs = _xs(len(phases_a)) + p50_a = [p["metrics"].get("latency_p50_ms") or 0 for p in phases_a] + p95_a = [p["metrics"].get("latency_p95_ms") or 0 for p in phases_a] + p50_b = [p["metrics"].get("latency_p50_ms") or 0 for p in phases_b] + p95_b = [p["metrics"].get("latency_p95_ms") or 0 for p in phases_b] + + fig, ax = plt.subplots(figsize=(8, 3.5)) + ax.plot(xs, [v/1000 for v in p50_a], color=C_A, marker="o", linewidth=2, label=f"{la} p50") + ax.plot(xs, [v/1000 for v in p95_a], color=C_A, marker="s", linewidth=2, linestyle="--", label=f"{la} p95") + ax.plot(xs, [v/1000 for v in p50_b], color=C_B, marker="o", linewidth=2, label=f"{lb} p50") + ax.plot(xs, [v/1000 for v in p95_b], color=C_B, marker="s", linewidth=2, linestyle="--", label=f"{lb} p95") + _finish(ax, fig, labels, "Transaction Latency", "Latency (s)") + return _fig_to_b64(fig) + + +def chart_mempool(phases_a, phases_b, labels, la, lb) -> str: + """Compare per-sub-pool peak depth (count) between two experiments. + + Reth caps each sub-pool independently on both count and size — this chart + surfaces count (the dimension easier to read across phases); the + accompanying phase-summary table carries the matching MB values so the + size dimension stays available without doubling the chart count. + """ + xs = _xs(len(phases_a)) + + def get(phases, key): + return [p["metrics"].get(key) or 0 for p in phases] + + series = [ + ("Pending Pool", "max_mempool", "avg_pending_mempool"), + ("Queued Pool", "max_queued_mempool", "avg_queued_mempool"), + ("Basefee Pool", "max_basefee_mempool", "avg_basefee_mempool"), + ] + # Drop sub-pools that stay at zero in both runs — keeps the figure tight. + active = [ + (title, peak_key, avg_key) + for title, peak_key, avg_key in series + if any(v > 0 for v in get(phases_a, peak_key) + get(phases_b, peak_key)) + ] or [series[0]] + + fig, axes = plt.subplots( + 1, len(active), figsize=(4.5 * len(active), 3.5), sharey=False + ) + if len(active) == 1: + axes = [axes] + + for ax, (title, peak_key, avg_key) in zip(axes, active): + pk_a = get(phases_a, peak_key) + av_a = get(phases_a, avg_key) + pk_b = get(phases_b, peak_key) + av_b = get(phases_b, avg_key) + _bar_pair(ax, xs, pk_a, pk_b, f"{la} peak", f"{lb} peak") + ax.plot(xs, av_a, color=C_A, marker="o", linewidth=1.5, linestyle=":", label=f"{la} avg") + ax.plot(xs, av_b, color=C_B, marker="o", linewidth=1.5, linestyle=":", label=f"{lb} avg") + ax.set_xticks(xs) + ax.set_xticklabels(labels) + ax.set_xlabel("Offered TPS") + ax.set_ylabel("Transactions") + ax.set_title(title) + ax.grid(axis="y", color=C_GRID) + ax.legend(fontsize=7) + + fig.tight_layout() + return _fig_to_b64(fig) + + +def chart_cpu(phases_a, phases_b, labels, la, lb) -> str: + xs = _xs(len(phases_a)) + avg_cpu_a = [p["metrics"].get("el_cpu_avg_pct") or 0 for p in phases_a] + max_cpu_a = [p["metrics"].get("el_cpu_max_pct") or 0 for p in phases_a] + avg_cpu_b = [p["metrics"].get("el_cpu_avg_pct") or 0 for p in phases_b] + max_cpu_b = [p["metrics"].get("el_cpu_max_pct") or 0 for p in phases_b] + + fig, ax = plt.subplots(figsize=(8, 3.5)) + _bar_pair(ax, xs, avg_cpu_a, avg_cpu_b, f"{la} avg", f"{lb} avg") + ax.plot(xs, max_cpu_a, color=C_A, marker="^", linewidth=1.5, linestyle="--", label=f"{la} max") + ax.plot(xs, max_cpu_b, color=C_B, marker="^", linewidth=1.5, linestyle="--", label=f"{lb} max") + ax.axhline(200, color="grey", linestyle=":", linewidth=1, alpha=0.7, label="200% (2 cores)") + _finish(ax, fig, labels, "EL CPU Utilisation", "CPU %") + return _fig_to_b64(fig) + + +def chart_memory(phases_a, phases_b, labels, la, lb) -> str: + xs = _xs(len(phases_a)) + avg_a = [p["metrics"].get("el_mem_avg_mb") or 0 for p in phases_a] + avg_b = [p["metrics"].get("el_mem_avg_mb") or 0 for p in phases_b] + peak_a = [p["metrics"].get("el_mem_peak_mb") or 0 for p in phases_a] + peak_b = [p["metrics"].get("el_mem_peak_mb") or 0 for p in phases_b] + + fig, axes = plt.subplots(1, 2, figsize=(12, 3.5)) + for ax, vals_a, vals_b, title in [ + (axes[0], avg_a, avg_b, "EL Avg Resident Memory (MiB)"), + (axes[1], peak_a, peak_b, "EL Peak Resident Memory (MiB)"), + ]: + _line_pair(ax, xs, vals_a, vals_b, la, lb) + ax.set_xticks(xs) + ax.set_xticklabels(labels) + ax.set_xlabel("Offered TPS") + ax.set_ylabel("Resident Memory (MiB)") + ax.set_title(title) + ax.grid(axis="y", color=C_GRID) + ax.legend(fontsize=8) + fig.tight_layout() + return _fig_to_b64(fig) + + +def chart_block_timing(phases_a, phases_b, labels, la, lb) -> str: + xs = _xs(len(phases_a)) + build_a = [p["metrics"].get("avg_block_build_time_ms") or 0 for p in phases_a] + build_b = [p["metrics"].get("avg_block_build_time_ms") or 0 for p in phases_b] + final_a = [p["metrics"].get("avg_block_finalize_time_ms") or 0 for p in phases_a] + final_b = [p["metrics"].get("avg_block_finalize_time_ms") or 0 for p in phases_b] + + fig, axes = plt.subplots(1, 2, figsize=(12, 3.5)) + for ax, vals_a, vals_b, title in [ + (axes[0], build_a, build_b, "Block Build Time (ms)"), + (axes[1], final_a, final_b, "Block Finalize Time (ms)"), + ]: + _line_pair(ax, xs, vals_a, vals_b, la, lb) + ax.set_xticks(xs) + ax.set_xticklabels(labels) + ax.set_xlabel("Offered TPS") + ax.set_ylabel("Time (ms)") + ax.set_title(title) + ax.grid(axis="y", color=C_GRID) + ax.legend(fontsize=8) + fig.tight_layout() + return _fig_to_b64(fig) + + +def chart_block_time(phases_a, phases_b, labels, la, lb) -> str: + xs = _xs(len(phases_a)) + bt_a = [p["metrics"].get("avg_block_time_s") or 0 for p in phases_a] + bt_b = [p["metrics"].get("avg_block_time_s") or 0 for p in phases_b] + + fig, ax = plt.subplots(figsize=(8, 3.5)) + _line_pair(ax, xs, bt_a, bt_b, la, lb) + _finish(ax, fig, labels, "Average Block Time", "Seconds") + return _fig_to_b64(fig) + + +def chart_signals(phases_a, phases_b, labels, la, lb) -> str: + signal_order = list(SIGNAL_LABELS.keys()) + n_phases = len(phases_a) + n_sigs = len(signal_order) + + fig, axes = plt.subplots(1, 2, figsize=(max(8, n_phases*2), 3), sharey=True) + + for ax, phases, title in [(axes[0], phases_a, la), (axes[1], phases_b, lb)]: + grid = [[1 if s in phase.get("signals", []) else 0 + for phase in phases] + for s in signal_order] + ax.imshow(grid, cmap="YlOrRd", vmin=0, vmax=1, aspect="auto") + ax.set_xticks(_xs(n_phases)) + ax.set_xticklabels(labels, fontsize=8) + ax.set_yticks(_xs(n_sigs)) + ax.set_yticklabels([SIGNAL_LABELS[s] for s in signal_order], fontsize=8) + ax.set_xlabel("Offered TPS") + ax.set_title(title) + + fig.tight_layout() + return _fig_to_b64(fig) + + +# ── phase table ─────────────────────────────────────────────────────────────── + +def phase_table_rows(phases_a, phases_b) -> "Markup": + rows = [] + for pa, pb in zip(phases_a, phases_b): + rate = pa["offered_tps"] + ma, mb = pa.get("metrics", {}), pb.get("metrics", {}) + sigs_a = " ".join(f'{SIGNAL_LABELS.get(s, escape(s))}' for s in pa.get("signals", [])) or "—" + sigs_b = " ".join(f'{SIGNAL_LABELS.get(s, escape(s))}' for s in pb.get("signals", [])) or "—" + + def row(label, a_raw, b_raw, a_fmt, b_fmt, delta_fn=None): + delta = "" + if delta_fn and a_raw is not None and b_raw is not None: + d = delta_fn(a_raw, b_raw) + cls = "better" if d > 0 else ("worse" if d < 0 else "") + sign = "+" if d > 0 else "" + delta = f'{sign}{d:.1f}%' + return f"{label}{a_fmt}{b_fmt}{delta}" + + def pct_better_higher(a, b): + return (b - a) / a * 100 if a else 0 + def pct_better_lower(a, b): + return (a - b) / a * 100 if a else 0 + + otps_a = _fmt(ma.get("actual_offered_tps"), ".0f") + otps_b = _fmt(mb.get("actual_offered_tps"), ".0f") + obps_a = _fmt_gas(ma.get("actual_offered_bytes_per_sec")) + obps_b = _fmt_gas(mb.get("actual_offered_bytes_per_sec")) + tps_a = _fmt(ma.get("actual_tps"), ".0f") + tps_b = _fmt(mb.get("actual_tps"), ".0f") + gas_a = _fmt_gas(ma.get("gas_per_sec")) + gas_b = _fmt_gas(mb.get("gas_per_sec")) + fill_a = _fmt(ma.get("fill_pct"), ".1f", "%") + fill_b = _fmt(mb.get("fill_pct"), ".1f", "%") + bld_a = _fmt(ma.get("avg_block_build_time_ms"), ".0f", " ms") + bld_b = _fmt(mb.get("avg_block_build_time_ms"), ".0f", " ms") + fin_a = _fmt(ma.get("avg_block_finalize_time_ms"), ".0f", " ms") + fin_b = _fmt(mb.get("avg_block_finalize_time_ms"), ".0f", " ms") + mema_a = _fmt(ma.get("el_mem_avg_mb"), ".0f", " MiB") + mema_b = _fmt(mb.get("el_mem_avg_mb"), ".0f", " MiB") + memp_a = _fmt(ma.get("el_mem_peak_mb"), ".0f", " MiB") + memp_b = _fmt(mb.get("el_mem_peak_mb"), ".0f", " MiB") + avgsd_a = _fmt_avg_sd(ma.get("latency_avg_ms"), ma.get("latency_stddev_ms")) + avgsd_b = _fmt_avg_sd(mb.get("latency_avg_ms"), mb.get("latency_stddev_ms")) + p50_a = _fmt(ma.get("latency_p50_ms"), ".0f", " ms") + p50_b = _fmt(mb.get("latency_p50_ms"), ".0f", " ms") + p95_a = _fmt(ma.get("latency_p95_ms"), ".0f", " ms") + p95_b = _fmt(mb.get("latency_p95_ms"), ".0f", " ms") + pkp_a = _fmt_count_mb(ma.get("max_mempool"), ma.get("max_pending_size_mb")) + pkp_b = _fmt_count_mb(mb.get("max_mempool"), mb.get("max_pending_size_mb")) + avp_a = _fmt_count_mb(ma.get("avg_pending_mempool"), ma.get("avg_pending_size_mb")) + avp_b = _fmt_count_mb(mb.get("avg_pending_mempool"), mb.get("avg_pending_size_mb")) + pkq_a = _fmt_count_mb(ma.get("max_queued_mempool"), ma.get("max_queued_size_mb")) + pkq_b = _fmt_count_mb(mb.get("max_queued_mempool"), mb.get("max_queued_size_mb")) + avq_a = _fmt_count_mb(ma.get("avg_queued_mempool"), ma.get("avg_queued_size_mb")) + avq_b = _fmt_count_mb(mb.get("avg_queued_mempool"), mb.get("avg_queued_size_mb")) + pkbf_a = _fmt_count_mb(ma.get("max_basefee_mempool"), ma.get("max_basefee_size_mb")) + pkbf_b = _fmt_count_mb(mb.get("max_basefee_mempool"), mb.get("max_basefee_size_mb")) + avbf_a = _fmt_count_mb(ma.get("avg_basefee_mempool"), ma.get("avg_basefee_size_mb")) + avbf_b = _fmt_count_mb(mb.get("avg_basefee_mempool"), mb.get("avg_basefee_size_mb")) + ca_a = _fmt(ma.get("el_cpu_avg_pct"), ".0f", "%") + ca_b = _fmt(mb.get("el_cpu_avg_pct"), ".0f", "%") + cm_a = _fmt(ma.get("el_cpu_max_pct"), ".0f", "%") + cm_b = _fmt(mb.get("el_cpu_max_pct"), ".0f", "%") + cca_a = _fmt(ma.get("cl_cpu_avg_pct"), ".0f", "%") + cca_b = _fmt(mb.get("cl_cpu_avg_pct"), ".0f", "%") + ccm_a = _fmt(ma.get("cl_cpu_max_pct"), ".0f", "%") + ccm_b = _fmt(mb.get("cl_cpu_max_pct"), ".0f", "%") + + rows.append(f""" +{rate} TPS offered +{row("Offered TPS (spammer)", ma.get("actual_offered_tps"), mb.get("actual_offered_tps"), otps_a, otps_b, pct_better_higher)} +{row("Offered B/s (spammer)", ma.get("actual_offered_bytes_per_sec"), mb.get("actual_offered_bytes_per_sec"), obps_a, obps_b, pct_better_higher)} +{row("Actual TPS", ma.get("actual_tps"), mb.get("actual_tps"), tps_a, tps_b, pct_better_higher)} +{row("Gas/s", ma.get("gas_per_sec"), mb.get("gas_per_sec"), gas_a, gas_b)} +{row("Fill %", ma.get("fill_pct"), mb.get("fill_pct"), fill_a, fill_b)} +{row("Build time", ma.get("avg_block_build_time_ms"), mb.get("avg_block_build_time_ms"), bld_a, bld_b, pct_better_lower)} +{row("Finalize time", ma.get("avg_block_finalize_time_ms"), mb.get("avg_block_finalize_time_ms"), fin_a, fin_b, pct_better_lower)} +{row("Avg±SD latency", ma.get("latency_avg_ms"), mb.get("latency_avg_ms"), avgsd_a, avgsd_b, pct_better_lower)} +{row("p50 latency", ma.get("latency_p50_ms"), mb.get("latency_p50_ms"), p50_a, p50_b, pct_better_lower)} +{row("p95 latency", ma.get("latency_p95_ms"), mb.get("latency_p95_ms"), p95_a, p95_b, pct_better_lower)} +{row("Peak pending (count, MB)", ma.get("max_mempool"), mb.get("max_mempool"), pkp_a, pkp_b)} +{row("Avg pending (count, MB)", ma.get("avg_pending_mempool"), mb.get("avg_pending_mempool"), avp_a, avp_b)} +{row("Peak queued (count, MB)", ma.get("max_queued_mempool"), mb.get("max_queued_mempool"), pkq_a, pkq_b)} +{row("Avg queued (count, MB)", ma.get("avg_queued_mempool"), mb.get("avg_queued_mempool"), avq_a, avq_b)} +{row("Peak basefee (count, MB)", ma.get("max_basefee_mempool"), mb.get("max_basefee_mempool"), pkbf_a, pkbf_b)} +{row("Avg basefee (count, MB)", ma.get("avg_basefee_mempool"), mb.get("avg_basefee_mempool"), avbf_a, avbf_b)} +{row("EL CPU avg", ma.get("el_cpu_avg_pct"), mb.get("el_cpu_avg_pct"), ca_a, ca_b)} +{row("EL CPU max", ma.get("el_cpu_max_pct"), mb.get("el_cpu_max_pct"), cm_a, cm_b)} +{row("CL CPU avg", ma.get("cl_cpu_avg_pct"), mb.get("cl_cpu_avg_pct"), cca_a, cca_b)} +{row("CL CPU max", ma.get("cl_cpu_max_pct"), mb.get("cl_cpu_max_pct"), ccm_a, ccm_b)} +{row("EL mem avg", ma.get("el_mem_avg_mb"), mb.get("el_mem_avg_mb"), mema_a, mema_b, pct_better_lower)} +{row("EL mem peak", ma.get("el_mem_peak_mb"), mb.get("el_mem_peak_mb"), memp_a, memp_b, pct_better_lower)} +Signals{sigs_a}{sigs_b} +""") + return Markup("\n".join(rows)) + + +# ── HTML template ───────────────────────────────────────────────────────────── + +_TEMPLATE = """\ + + +{{ title }} + + + +

{{ title }}

+ + + + + + + + + + + + + + + + + + +
A — {{ label_a }}B — {{ label_b }}
EL image{{ image_a }}{{ image_b }}
Node types{{ node_types_a }}{{ node_types_b }}
Node size{{ node_size_a }}{{ node_size_b }}
Node disk{{ node_disk_a }}{{ node_disk_b }}
EL resources{{ el_resources_a }}{{ el_resources_b }}
CL resources{{ cl_resources_a }}{{ cl_resources_b }}
Prefund / account{{ prefund_a }}{{ prefund_b }}
Block gas limit{{ block_gas_a }}{{ block_gas_b }}
Experiment{{ exp_id_a }}{{ exp_id_b }}
Duration{{ duration_a }}{{ duration_b }}
Phases{{ rates_a }}{{ rates_b }}
Hold / Ramp / Cool{{ hold_a }}s / {{ ramp_a }}s / {{ cool_a }}s{{ hold_b }}s / {{ ramp_b }}s / {{ cool_b }}s
Generators{{ gen_a }}{{ gen_b }}
TX mix{{ mix_a }}{{ mix_b }}
EL storage{{ storage_a }}{{ storage_b }}
+ +

Phase Summary

+ + + + + + {{ phase_rows }} + +
MetricA — {{ label_a }}B — {{ label_b }}Δ (B vs A)
+ +

Charts

+
+
Throughput
+
Gas throughput
+
+
+
Block fill
+
Block time
+
+
+
Latency
+
CPU
+
+
+
Mempool
+
+
+
Block build & finalize time
+
+
+
EL resident memory
+
+
+
Signals
+
+ + + +""" + + +# ── main ────────────────────────────────────────────────────────────────────── + +def _duration(exp): + fmt = "%Y-%m-%dT%H:%M:%S.%fZ" + try: + s = datetime.strptime(exp["started_at"], fmt).replace(tzinfo=timezone.utc) + e = datetime.strptime(exp["ended_at"], fmt).replace(tzinfo=timezone.utc) + secs = int((e - s).total_seconds()) + return f"{secs//60}m {secs%60}s" + except Exception: + return "—" + + +def main(): + import argparse + + parser = argparse.ArgumentParser( + description="Compare two saturation experiments and write a self-contained HTML report.", + ) + parser.add_argument("exp_dir_a", type=Path, help="First experiment directory") + parser.add_argument("exp_dir_b", type=Path, help="Second experiment directory") + parser.add_argument("output", type=Path, help="Output HTML path") + parser.add_argument( + "--label-a", + help="Display label for experiment A (defaults to its EL image tag)", + ) + parser.add_argument( + "--label-b", + help="Display label for experiment B (defaults to its EL image tag)", + ) + args = parser.parse_args() + + dir_a = args.exp_dir_a + dir_b = args.exp_dir_b + out = args.output + + with open(dir_a / "experiment.json") as f: + exp_a = json.load(f) + with open(dir_b / "experiment.json") as f: + exp_b = json.load(f) + + def _topo(params: dict) -> dict: + t = params.get("topology") + if t: + return t + return _topology_from_manifest(params.get("manifest") or {}) + + topo_a = _topo(exp_a["parameters"]) + topo_b = _topo(exp_b["parameters"]) + p_a = exp_a["parameters"] + p_b = exp_b["parameters"] + + label_a = args.label_a or _label(topo_a.get("image_el")) + label_b = args.label_b or _label(topo_b.get("image_el")) + + # Align phases by offered_tps so reports work across runs that swept + # different rate sets (e.g. baseline included 2000 TPS, hardware-varied + # runs stopped at 1800). + rates_a = {p["offered_tps"]: p for p in exp_a["phases"]} + rates_b = {p["offered_tps"]: p for p in exp_b["phases"]} + common = sorted(set(rates_a) & set(rates_b)) + if len(common) != len(rates_a) or len(common) != len(rates_b): + print(f" note: aligning on common rates {common}; " + f"a had {sorted(rates_a)}, b had {sorted(rates_b)}", flush=True) + phases_a = [rates_a[r] for r in common] + phases_b = [rates_b[r] for r in common] + x_labels = [str(r) for r in common] + + print("Rendering charts...", flush=True) + charts = { + "throughput": chart_throughput(phases_a, phases_b, x_labels, label_a, label_b), + "gas": chart_gas(phases_a, phases_b, x_labels, label_a, label_b), + "fill": chart_fill(phases_a, phases_b, x_labels, label_a, label_b), + "latency": chart_latency(phases_a, phases_b, x_labels, label_a, label_b), + "mempool": chart_mempool(phases_a, phases_b, x_labels, label_a, label_b), + "cpu": chart_cpu(phases_a, phases_b, x_labels, label_a, label_b), + "block_time": chart_block_time(phases_a, phases_b, x_labels, label_a, label_b), + "block_timing": chart_block_timing(phases_a, phases_b, x_labels, label_a, label_b), + "memory": chart_memory(phases_a, phases_b, x_labels, label_a, label_b), + "signals": chart_signals(phases_a, phases_b, x_labels, label_a, label_b), + } + + ctx = dict( + title=f"Saturation Experiment Comparison: {label_a} vs {label_b}", + label_a=label_a, label_b=label_b, + image_a=_short(topo_a.get("image_el")), image_b=_short(topo_b.get("image_el")), + node_size_a=topo_a.get("node_size") or "—", node_size_b=topo_b.get("node_size") or "—", + node_types_a=_fmt_node_types(topo_a.get("nodes_by_type") or {}), + node_types_b=_fmt_node_types(topo_b.get("nodes_by_type") or {}), + node_disk_a=_fmt_node_disk(topo_a), node_disk_b=_fmt_node_disk(topo_b), + el_resources_a=_fmt_container_resources(topo_a.get("el_cpu_limit"), topo_a.get("el_memory_limit_gb")), + el_resources_b=_fmt_container_resources(topo_b.get("el_cpu_limit"), topo_b.get("el_memory_limit_gb")), + cl_resources_a=_fmt_container_resources(topo_a.get("cl_cpu_limit"), topo_a.get("cl_memory_limit_gb")), + cl_resources_b=_fmt_container_resources(topo_b.get("cl_cpu_limit"), topo_b.get("cl_memory_limit_gb")), + prefund_a=_fmt_usdc(topo_a.get("extra_account_balance_usdc")), + prefund_b=_fmt_usdc(topo_b.get("extra_account_balance_usdc")), + block_gas_a=topo_a.get("block_gas_limit") or "—", + block_gas_b=topo_b.get("block_gas_limit") or "—", + exp_id_a=exp_a["experiment_id"], exp_id_b=exp_b["experiment_id"], + duration_a=_duration(exp_a), duration_b=_duration(exp_b), + rates_a=", ".join(str(p["offered_tps"]) for p in phases_a), + rates_b=", ".join(str(p["offered_tps"]) for p in phases_b), + hold_a=p_a.get("hold_secs", "—"), hold_b=p_b.get("hold_secs", "—"), + ramp_a=p_a.get("rampup_secs", "—"), ramp_b=p_b.get("rampup_secs", "—"), + cool_a=p_a.get("cooldown_secs", "—"), cool_b=p_b.get("cooldown_secs", "—"), + gen_a=p_a.get("generators", "—"), gen_b=p_b.get("generators", "—"), + mix_a=p_a.get("tx_mix", "—"), mix_b=p_b.get("tx_mix", "—"), + storage_a="V2" if topo_a.get("el_storage_v2", True) else "V1", + storage_b="V2" if topo_b.get("el_storage_v2", True) else "V1", + phase_rows=phase_table_rows(phases_a, phases_b), + charts=charts, + ) + env = Environment(autoescape=True) + html = env.from_string(_TEMPLATE).render(**ctx) + + out.write_text(html) + print(f"Written: {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/create-release-tag.sh b/scripts/create-release-tag.sh new file mode 100755 index 00000000..059ecac0 --- /dev/null +++ b/scripts/create-release-tag.sh @@ -0,0 +1,682 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + create-release-tag.sh --source --release-kind patch|minor|major [--as-release-candidate] [--release-domain domain] [--base-branch main] + create-release-tag.sh --tag [--release-domain domain] [--base-branch main] + +Environment: + PUSH_TAG=false Create the tag locally but do not push it to origin. + RELEASE_DOMAIN Optional release domain. + TAG_PREFIX Internal release tag prefix. + RELEASE_BRANCH_PREFIX + Internal release branch prefix. + BASE_BRANCH=main + Base branch used for minor and major releases. + MAIN_BRANCH Deprecated alias for BASE_BRANCH. + ALLOW_STALE_RELEASE_REFS=true + Continue if fetching origin refs fails. Intended only for + local/offline testing. +USAGE +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/release-config.sh +source "${SCRIPT_DIR}/release-config.sh" + +SOURCE_REF="latest" +RELEASE_KIND="" +TAG="" +AS_RELEASE_CANDIDATE=false +PUSH_TAG="${PUSH_TAG:-true}" +CREATED_NEW_TAG=false +CREATED_RELEASE_BRANCH=false +RESOLVED_SOURCE_REF="" +SOURCE_BRANCH="" +RELEASE_DOMAIN="${RELEASE_DOMAIN:-}" +BASE_BRANCH="${BASE_BRANCH:-${MAIN_BRANCH:-main}}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --source) SOURCE_REF="${2:?missing source ref}"; shift 2 ;; + --release-kind) RELEASE_KIND="${2:?missing release kind}"; shift 2 ;; + --tag) TAG="${2:?missing tag}"; shift 2 ;; + --as-release-candidate) AS_RELEASE_CANDIDATE=true; shift ;; + --release-domain) RELEASE_DOMAIN="${2-}"; shift 2 ;; + --base-branch) BASE_BRANCH="${2:?missing base branch}"; shift 2 ;; + --main-branch) BASE_BRANCH="${2:?missing main branch}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ -n "${RELEASE_KIND}" && -n "${TAG}" ]]; then + echo "Set either --release-kind or tag, not both" >&2 + exit 1 +fi + +if [[ -z "${RELEASE_KIND}" && -z "${TAG}" ]]; then + echo "Set release_kind or tag" >&2 + exit 1 +fi + +validate_namespace_ref() { + local label="$1" + local ref="$2" + + if ! git check-ref-format "${ref}" >/dev/null 2>&1; then + echo "Invalid ${label}: ${ref}" >&2 + exit 1 + fi +} + +if ! release_validate_domain "${RELEASE_DOMAIN}"; then + echo "Invalid release domain: ${RELEASE_DOMAIN}" >&2 + exit 1 +fi +TAG_PREFIX="v" +if ! RELEASE_BRANCH_PREFIX="$(release_effective_branch_prefix "${RELEASE_DOMAIN}")"; then + echo "Invalid release domain: ${RELEASE_DOMAIN}" >&2 + exit 1 +fi + +BASE_BRANCH="${BASE_BRANCH:-main}" + +if [[ -z "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "release branch prefix cannot be empty" >&2 + exit 1 +fi +if [[ -z "${BASE_BRANCH}" ]]; then + echo "base branch cannot be empty" >&2 + exit 1 +fi +validate_namespace_ref "release branch prefix" "refs/heads/${RELEASE_BRANCH_PREFIX}0.0" +validate_namespace_ref "base branch" "refs/heads/${BASE_BRANCH}" + +if ! git fetch origin '+refs/heads/*:refs/remotes/origin/*' --tags --prune >/dev/null 2>&1; then + if [[ "${ALLOW_STALE_RELEASE_REFS:-false}" != true ]]; then + echo "Unable to fetch origin refs; refusing to calculate a release from stale local state" >&2 + exit 1 + fi + echo "Warning: unable to fetch origin; continuing with local refs because ALLOW_STALE_RELEASE_REFS=true." >&2 +fi + +# TAG_PREFIX is "v" for every domain, so `git tag --list` globs below are +# intentionally coarse (they can match other domains' tags); this is the sole +# gate that keeps tag resolution scoped to RELEASE_DOMAIN. +tag_version() { + local tag="$1" + release_tag_version_for_domain "${tag}" "${RELEASE_DOMAIN}" +} + +validate_tag() { + local tag="$1" + local version + + version="$(tag_version "${tag}")" || return 1 + [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$ ]] +} + +format_final_tag() { + if [[ -z "${RELEASE_DOMAIN}" ]]; then + printf '%s%s.%s.%s\n' "${TAG_PREFIX}" "$1" "$2" "$3" + else + printf '%s%s.%s.%s-%s\n' "${TAG_PREFIX}" "$1" "$2" "$3" "${RELEASE_DOMAIN}" + fi +} + +format_rc_tag() { + if [[ -z "${RELEASE_DOMAIN}" ]]; then + printf '%s%s.%s.%s-rc%s\n' "${TAG_PREFIX}" "$1" "$2" "$3" "$4" + else + printf '%s%s.%s.%s-%s.rc%s\n' "${TAG_PREFIX}" "$1" "$2" "$3" "${RELEASE_DOMAIN}" "$4" + fi +} + +release_line_from_branch() { + local branch="$1" + branch="${branch#origin/}" + if [[ "${branch:0:${#RELEASE_BRANCH_PREFIX}}" != "${RELEASE_BRANCH_PREFIX}" ]]; then + return 1 + fi + + local line="${branch:${#RELEASE_BRANCH_PREFIX}}" + [[ "${line}" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+|x))?$ ]] +} + +tag_points_to_ref() { + local tag="$1" + local ref="$2" + + if ! git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then + return 1 + fi + + local tag_sha ref_sha + tag_sha="$(git rev-list -n1 "${tag}")" + ref_sha="$(git rev-parse "${ref}^{commit}")" + [[ "${tag_sha}" == "${ref_sha}" ]] +} + +configure_tag_identity() { + if [[ "${GITHUB_ACTIONS:-}" != "true" ]]; then + return + fi + + if ! git config user.name >/dev/null; then + git config user.name "github-actions[bot]" + fi + if ! git config user.email >/dev/null; then + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + fi +} + +latest_release_branch() { + local branches latest + branches="$( + while IFS= read -r branch; do + branch="${branch#origin/}" + if release_line_from_branch "${branch}"; then + printf '%s\t%s\n' "${branch:${#RELEASE_BRANCH_PREFIX}}" "${branch}" + fi + done < <(git for-each-ref --format='%(refname:short)' refs/heads refs/remotes/origin 2>/dev/null) | + sort -k1,1V | + awk -F'\t' '!seen[$2]++ {print $2}' || true + )" + latest="$(printf '%s\n' "${branches}" | tail -n1)" + if [[ -z "${latest}" ]]; then + echo "No release branch found matching ${RELEASE_BRANCH_PREFIX}X.Y, ${RELEASE_BRANCH_PREFIX}X.Y.Z, or ${RELEASE_BRANCH_PREFIX}X.Y.x" >&2 + exit 1 + fi + echo "${latest}" +} + +resolve_ref() { + local ref="$1" + local kind="$2" + if [[ "${ref}" == "latest" ]]; then + if [[ "${kind}" == "patch" ]]; then + ref="$(latest_release_branch)" + echo "Resolved latest release branch to ${ref}" >&2 + else + ref="${BASE_BRANCH}" + echo "Resolved latest source branch to ${ref} for ${kind} release" >&2 + fi + fi + if git rev-parse --verify --quiet "${ref}^{commit}" >/dev/null; then + echo "${ref}" + elif git rev-parse --verify --quiet "origin/${ref}^{commit}" >/dev/null; then + echo "origin/${ref}" + else + echo "Cannot resolve ref: ${ref}" >&2 + exit 1 + fi +} + +normalize_branch_name() { + local ref="$1" + ref="${ref#origin/}" + if [[ "${ref}" == "${BASE_BRANCH}" || "${ref:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${ref}" + return + fi + + local containing + containing="$( + while IFS= read -r branch; do + branch="${branch#origin/}" + if [[ "${branch}" == "${BASE_BRANCH}" || "${branch:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${branch}" + break + fi + done < <(git branch -r --contains "${ref}" --format='%(refname:short)' 2>/dev/null) || true + )" + if [[ -z "${containing}" ]]; then + containing="$( + while IFS= read -r branch; do + if [[ "${branch}" == "${BASE_BRANCH}" || "${branch:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${branch}" + break + fi + done < <(git branch --contains "${ref}" --format='%(refname:short)' 2>/dev/null) || true + )" + fi + echo "${containing}" +} + +latest_global_tag() { + local tag version final_tag + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + + version="$(tag_version "${tag}")" || continue + if [[ "${version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc[0-9]+$ ]]; then + final_tag="$(format_final_tag "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}")" + if git rev-parse --verify --quiet "refs/tags/${final_tag}" >/dev/null; then + continue + fi + fi + + echo "${tag}" + return + done < <(git tag --list "${TAG_PREFIX}[0-9]*" --sort=-v:refname) +} + +latest_tag() { + local source="$1" + local tag_filter="$2" + local tag version final_tag final_sha + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + + version="$(tag_version "${tag}")" || continue + if [[ "${version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc[0-9]+$ ]]; then + final_tag="$(format_final_tag "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}")" + if git rev-parse --verify --quiet "refs/tags/${final_tag}" >/dev/null; then + final_sha="$(git rev-list -n1 "${final_tag}")" + if git merge-base --is-ancestor "${final_sha}" "${source}"; then + continue + fi + fi + fi + + echo "${tag}" + return + done < <(git tag --merged "${source}" --list "${tag_filter}" --sort=-v:refname) +} + +latest_matching_rc_tag() { + local source="$1" + local final_tag="$2" + local rc_separator="-" + local tag + + [[ -n "${RELEASE_DOMAIN}" ]] && rc_separator="." + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + tag_version "${tag}" >/dev/null || continue + echo "${tag}" + return + done < <(git tag --merged "${source}" --list "${final_tag}${rc_separator}rc[0-9]*" --sort=-v:refname) +} + +latest_final_tag_for_candidate() { + local candidate_tag="$1" + local candidate_version major minor tag tag_filter + + candidate_version="$(tag_version "${candidate_tag}")" || return + if [[ ! "${candidate_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc[0-9]+)?$ ]]; then + return + fi + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + + tag_filter="${TAG_PREFIX}[0-9]*" + if [[ "${RELEASE_KIND}" == "patch" ]]; then + tag_filter="${TAG_PREFIX}${major}.${minor}.*" + fi + + if tag="$(latest_final_tag_for_prefix "${candidate_tag}" "${tag_filter}")"; then + echo "${tag}" + return + fi + +} + +latest_final_tag_for_prefix() { + local candidate_tag="$1" + local tag_filter="$2" + local tag version + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + [[ -n "${candidate_tag}" && "${tag}" == "${candidate_tag}" ]] && continue + + version="$(tag_version "${tag}")" || continue + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "${tag}" + return + fi + done < <(git tag --list "${tag_filter}" --sort=-v:refname) + + return 1 +} + +ensure_candidate_has_diff_from_previous_final() { + local candidate_tag="$1" + local target_sha="$2" + local previous_final_tag + + previous_final_tag="$(latest_final_tag_for_candidate "${candidate_tag}")" + if [[ -z "${previous_final_tag}" ]]; then + return + fi + + if git diff --quiet --no-ext-diff "${previous_final_tag}" "${target_sha}" -- .; then + echo "No changes since ${previous_final_tag}; refusing to create ${candidate_tag} at ${target_sha}" >&2 + exit 1 + fi +} + +continues_rc_line() { + local kind="$1" + local minor="$2" + local patch="$3" + + case "${kind}" in + patch) return 0 ;; + minor) [[ "${patch}" == 0 ]] ;; + major) [[ "${minor}" == 0 && "${patch}" == 0 ]] ;; + *) echo "Invalid release kind: ${kind}" >&2; exit 1 ;; + esac +} + +next_tag() { + local kind="$1" + local source="$2" + local latest latest_version major minor patch rc latest_is_rc tag_filter branch_name base_tag latest_uses_release_prefix + + tag_filter="${TAG_PREFIX}[0-9]*" + branch_name="$(normalize_branch_name "${source}")" + if release_line_from_branch "${branch_name}"; then + tag_filter="${TAG_PREFIX}${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.*" + fi + + if [[ "${kind}" == "patch" ]]; then + latest="$(latest_tag "${source}" "${tag_filter}")" + else + latest="$(latest_global_tag)" + fi + base_tag="$(format_final_tag 0 0 0)" + latest="${latest:-${base_tag}}" + latest_version="$(tag_version "${latest}")" + latest_uses_release_prefix=false + if tag_version "${latest}" >/dev/null 2>&1; then + latest_uses_release_prefix=true + fi + + latest_is_rc=false + rc=0 + if [[ "${latest_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + rc="${BASH_REMATCH[4]}" + latest_is_rc=true + elif [[ "${latest_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + else + echo "Latest tag has unsupported format: ${latest}" >&2 + exit 1 + fi + + if [[ "${AS_RELEASE_CANDIDATE}" == true ]]; then + if [[ "${latest_uses_release_prefix}" == true && "${latest_is_rc}" == true ]] && continues_rc_line "${kind}" "${minor}" "${patch}"; then + if tag_points_to_ref "${latest}" "${source}"; then + echo "Reusing ${latest}; source commit is already tagged" >&2 + printf '%s\n' "${latest}" + return + fi + + format_rc_tag "${major}" "${minor}" "${patch}" "$((rc + 1))" + return + fi + + case "${kind}" in + patch) patch=$((patch + 1)) ;; + minor) minor=$((minor + 1)); patch=0 ;; + major) major=$((major + 1)); minor=0; patch=0 ;; + *) echo "Invalid release kind: ${kind}" >&2; exit 1 ;; + esac + format_rc_tag "${major}" "${minor}" "${patch}" 1 + return + fi + + if [[ "${latest_uses_release_prefix}" == true && "${kind}" == "patch" && "${latest}" != "${base_tag}" && "${latest_is_rc}" == false ]] && tag_points_to_ref "${latest}" "${source}"; then + echo "Reusing ${latest}; source commit is already tagged" >&2 + printf '%s\n' "${latest}" + return + fi + + if [[ "${latest_uses_release_prefix}" == true && "${latest_is_rc}" == true ]] && continues_rc_line "${kind}" "${minor}" "${patch}"; then + echo "Promoting ${latest} to $(format_final_tag "${major}" "${minor}" "${patch}")" >&2 + format_final_tag "${major}" "${minor}" "${patch}" + return + fi + + case "${kind}" in + patch) patch=$((patch + 1)) ;; + minor) minor=$((minor + 1)); patch=0 ;; + major) major=$((major + 1)); minor=0; patch=0 ;; + *) echo "Invalid release kind: ${kind}" >&2; exit 1 ;; + esac + + format_final_tag "${major}" "${minor}" "${patch}" +} + +next_available_rc_tag() { + local tag="$1" + local version major minor patch rc + + while git rev-parse --verify "refs/tags/${tag}" >/dev/null 2>&1; do + version="$(tag_version "${tag}")" || { + echo "Tag already exists: ${tag}" >&2 + exit 1 + } + if [[ ! "${version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc([0-9]+)$ ]]; then + echo "Tag already exists: ${tag}" >&2 + exit 1 + fi + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + rc="${BASH_REMATCH[4]}" + tag="$(format_rc_tag "${major}" "${minor}" "${patch}" "$((rc + 1))")" + done + + echo "${tag}" +} + +ensure_next_line_release_branch() { + local release_branch="$1" + local target_sha="$2" + + if [[ -z "${RELEASE_KIND}" || "${RELEASE_KIND}" == "patch" ]]; then + return + fi + if [[ -z "${release_branch}" || "${SOURCE_BRANCH}" == "${release_branch}" ]]; then + return + fi + + if git rev-parse --verify --quiet "refs/remotes/origin/${release_branch}^{commit}" >/dev/null; then + if git merge-base --is-ancestor "${target_sha}" "origin/${release_branch}"; then + echo "Release branch already exists for ${TAG}: ${release_branch}" >&2 + return + fi + + echo "Release branch ${release_branch} exists but does not contain ${TAG}@${target_sha}" >&2 + exit 1 + fi + + if git rev-parse --verify --quiet "refs/heads/${release_branch}^{commit}" >/dev/null; then + if ! git merge-base --is-ancestor "${target_sha}" "${release_branch}"; then + echo "Local release branch ${release_branch} exists but does not contain ${TAG}@${target_sha}" >&2 + exit 1 + fi + else + git branch "${release_branch}" "${target_sha}" + fi + + CREATED_RELEASE_BRANCH=true +} + +ref_is_contained_by_release_branch() { + local ref="$1" + local branch + + while IFS= read -r branch; do + branch="${branch#origin/}" + if release_line_from_branch "${branch}"; then + return 0 + fi + done < <(git branch -r --contains "${ref}" --format='%(refname:short)' 2>/dev/null) + + while IFS= read -r branch; do + if release_line_from_branch "${branch}"; then + return 0 + fi + done < <(git branch --contains "${ref}" --format='%(refname:short)' 2>/dev/null) + + return 1 +} + +validate_release_source() { + local kind="$1" + local source_arg="$2" + local resolved_source_ref="$3" + local source_branch="$4" + local source_arg_branch="${source_arg#origin/}" + + if [[ "${kind}" == "patch" ]]; then + if [[ "${source_arg_branch}" == "${BASE_BRANCH}" ]]; then + echo "patch releases must start from ${RELEASE_BRANCH_PREFIX}X.Y or an explicit commit contained by a ${RELEASE_BRANCH_PREFIX}X.Y branch, not ${source_arg}" >&2 + exit 1 + fi + + if release_line_from_branch "${source_branch}" || + release_line_from_branch "${source_arg_branch}" || + ref_is_contained_by_release_branch "${resolved_source_ref}"; then + return + fi + + echo "patch releases must start from ${RELEASE_BRANCH_PREFIX}X.Y or an explicit commit contained by a ${RELEASE_BRANCH_PREFIX}X.Y branch, not ${source_arg}" >&2 + exit 1 + fi + + if release_line_from_branch "${source_branch}"; then + echo "${kind} releases must start from ${BASE_BRANCH} or an explicit ${BASE_BRANCH} commit, not ${source_branch}" >&2 + exit 1 + fi + + if [[ "${source_arg_branch:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${kind} releases must not use release branch source_ref=${source_arg}" >&2 + exit 1 + fi +} + +push_created_refs() { + local refs=() + + if [[ "${PUSH_TAG}" != true ]]; then + return + fi + + if [[ "${CREATED_NEW_TAG}" == true ]]; then + refs+=("refs/tags/${TAG}") + fi + if [[ "${CREATED_RELEASE_BRANCH}" == true ]]; then + refs+=("refs/heads/${RELEASE_BRANCH}") + fi + + if [[ ${#refs[@]} -gt 0 ]]; then + git push origin "${refs[@]}" + fi +} + +if [[ -n "${RELEASE_KIND}" ]]; then + RESOLVED_SOURCE_REF="$(resolve_ref "${SOURCE_REF}" "${RELEASE_KIND}")" + SOURCE_BRANCH="$(normalize_branch_name "${RESOLVED_SOURCE_REF}")" + validate_release_source "${RELEASE_KIND}" "${SOURCE_REF}" "${RESOLVED_SOURCE_REF}" "${SOURCE_BRANCH}" + TAG="$(next_tag "${RELEASE_KIND}" "${RESOLVED_SOURCE_REF}")" + TAG_TARGET_REF="${RESOLVED_SOURCE_REF}" + if [[ "${AS_RELEASE_CANDIDATE}" == true ]]; then + if ! tag_points_to_ref "${TAG}" "${RESOLVED_SOURCE_REF}"; then + TAG="$(next_available_rc_tag "${TAG}")" + fi + TAG_TARGET_REF="${RESOLVED_SOURCE_REF}" + fi + validate_tag "${TAG}" || { echo "Generated invalid tag: ${TAG}" >&2; exit 1; } + if git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then + TAG_TARGET_SHA="$(git rev-parse "${TAG_TARGET_REF}^{commit}")" + ensure_candidate_has_diff_from_previous_final "${TAG}" "${TAG_TARGET_SHA}" + if tag_points_to_ref "${TAG}" "${TAG_TARGET_REF}"; then + echo "Tag already exists at target commit: ${TAG}" >&2 + else + echo "Tag already exists: ${TAG}" >&2 + exit 1 + fi + else + TAG_VERSION="$(tag_version "${TAG}")" + if [[ "${AS_RELEASE_CANDIDATE}" != true && "${TAG_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + PROMOTED_RC_TAG="$(latest_matching_rc_tag "${RESOLVED_SOURCE_REF}" "${TAG}")" + if [[ -n "${PROMOTED_RC_TAG}" ]]; then + TAG_TARGET_REF="${PROMOTED_RC_TAG}" + fi + fi + configure_tag_identity + TAG_TARGET_SHA="$(git rev-parse "${TAG_TARGET_REF}^{commit}")" + ensure_candidate_has_diff_from_previous_final "${TAG}" "${TAG_TARGET_SHA}" + git -c tag.gpgSign=false tag -a "${TAG}" -m "Release ${TAG}" "${TAG_TARGET_SHA}" + CREATED_NEW_TAG=true + fi +else + validate_tag "${TAG}" || { echo "Invalid tag: ${TAG}" >&2; exit 1; } + if ! git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Explicit tag does not exist: ${TAG}" >&2 + exit 1 + fi + RESOLVED_SOURCE_REF="${TAG}" + SOURCE_BRANCH="$(normalize_branch_name "${TAG}")" +fi + +SHA="$(git rev-list -n1 "${TAG}")" +SHORT_SHA="$(git rev-parse --short=8 "${SHA}")" +VERSION="$(tag_version "${TAG}")" +IS_RELEASE_CANDIDATE=false +RELEASE_BRANCH="" +if [[ "${VERSION}" =~ -rc[0-9]+$ ]]; then + IS_RELEASE_CANDIDATE=true +fi +if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+(-rc[0-9]+)?$ ]]; then + RELEASE_BRANCH="${RELEASE_BRANCH_PREFIX}${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" +fi + +if [[ -n "${RELEASE_KIND}" ]]; then + ensure_next_line_release_branch "${RELEASE_BRANCH}" "${SHA}" + push_created_refs +fi + +CHANNEL="preview" +if git merge-base --is-ancestor "${SHA}" "origin/${BASE_BRANCH}" 2>/dev/null || git merge-base --is-ancestor "${SHA}" "${BASE_BRANCH}" 2>/dev/null; then + CHANNEL="latest" +elif git branch -r --contains "${SHA}" --format='%(refname:short)' 2>/dev/null | grep -qF "origin/${RELEASE_BRANCH_PREFIX}"; then + CHANNEL="release" +elif git branch --contains "${SHA}" --format='%(refname:short)' 2>/dev/null | grep -qF "${RELEASE_BRANCH_PREFIX}"; then + CHANNEL="release" +fi + +{ + echo "release_domain=${RELEASE_DOMAIN}" + echo "tag_prefix=${TAG_PREFIX}" + echo "release_branch_prefix=${RELEASE_BRANCH_PREFIX}" + echo "tag=${TAG}" + echo "sha=${SHA}" + echo "short_sha=${SHORT_SHA}" + echo "version=${VERSION}" + echo "is_release_candidate=${IS_RELEASE_CANDIDATE}" + echo "channel=${CHANNEL}" + echo "created_new_tag=${CREATED_NEW_TAG}" + echo "created_release_branch=${CREATED_RELEASE_BRANCH}" + echo "source_ref=${RESOLVED_SOURCE_REF}" + echo "source_branch=${SOURCE_BRANCH}" + echo "release_branch=${RELEASE_BRANCH}" +} > release.env + +cat release.env diff --git a/scripts/genesis/genesis.ts b/scripts/genesis/genesis.ts index 2bb3cfc5..d5444923 100644 --- a/scripts/genesis/genesis.ts +++ b/scripts/genesis/genesis.ts @@ -33,7 +33,14 @@ import { buildProtocolConfigGenesisAllocs, schemaProtocolConfig } from './Protoc import { BuilderContext } from './context' import { Address, fromHex, Hex, toHex } from 'viem' import { buildValidatorManagerGenesisAllocs, schemaValidatorManager } from './ValidatorManager' -import { memoAddress, multicall3FromAddress, nativeCoinAutorityAddress, nativeCoinControlAddress } from './addresses' +import { + gasGuzzlerAddress, + memoAddress, + multicall3FromAddress, + nativeCoinAutorityAddress, + nativeCoinControlAddress, + testTokenAddress, +} from './addresses' const emptyPrecompileStart = 0x1800000000000000000000000000000000000002n const emptyPrecompileEnd = 0x18000000000000000000000000000000000000ffn @@ -92,6 +99,7 @@ export const schemaGenesisConfig = z zero5Block: z.number().optional(), zero6Block: z.number().optional(), zero7Time: z.number().optional(), + zero8Time: z.number().optional(), osakaTime: z.number().optional(), }) .optional(), @@ -127,7 +135,7 @@ export const schemaGenesisConfig = z export type GenesisConfig = z.infer // Defines hardfork name, this is used for genesis builder command line arguments. -export const hardforkNameSchema = z.enum(['zero3', 'zero4', 'zero5', 'zero6', 'zero7']) +export const hardforkNameSchema = z.enum(['zero3', 'zero4', 'zero5', 'zero6', 'zero7', 'zero8']) // Defines the mapping from hardfork name to genesis hardforks initialize setting. export function initialHardforksByName(hardforkName: z.infer): GenesisConfig['hardforks'] { @@ -137,6 +145,15 @@ export function initialHardforksByName(hardforkName: z.infer p.address) ?? [] const balancePerAccount = 1_000_000n * 10n ** 18n // 1M tokens diff --git a/scripts/hardhat/tasks/genesis.ts b/scripts/hardhat/tasks/genesis.ts index 14fef307..ec85bbcd 100644 --- a/scripts/hardhat/tasks/genesis.ts +++ b/scripts/hardhat/tasks/genesis.ts @@ -54,7 +54,12 @@ task('genesis', 'Generate the genesis file') undefined, types.string, ) - .addOptionalParam('hardfork', 'hardfork to use, available: zero3, zero4, zero5, zero6, zero7', 'zero7', types.string) + .addOptionalParam( + 'hardfork', + 'hardfork to use, available: zero3, zero4, zero5, zero6, zero7, zero8', + 'zero8', + types.string, + ) .addOptionalParam( 'extraAccountBalance', 'Initial balance for each prefunded account in whole token units (default: 1000000)', @@ -105,9 +110,9 @@ task('genesis', 'Generate the genesis file') ...buildOptions, ...(parsedValidatorNames && parsedValidatorNames.length > 0 ? { - validatorNames: parsedValidatorNames, - outputControllersConfig: outputPathWithSuffix('controllers-config'), - } + validatorNames: parsedValidatorNames, + outputControllersConfig: outputPathWithSuffix('controllers-config'), + } : {}), ...(parsedVotingPowers && parsedVotingPowers.length > 0 ? { votingPowers: parsedVotingPowers } : {}), outputGenesisConfig: outputPathWithSuffix('config'), diff --git a/scripts/localdev.mjs b/scripts/localdev.mjs index fda348b0..ca164e7e 100755 --- a/scripts/localdev.mjs +++ b/scripts/localdev.mjs @@ -107,7 +107,6 @@ const localdevManager = new ProcessManager({ `--http.port=${options.port ?? 8545}`, '--metrics=8080', '--rpc.txfeecap=1000', - '--arc.denylist.enabled', ] if (options.bin) { return nodeArgs diff --git a/scripts/release-config.sh b/scripts/release-config.sh index 5a5a053e..84f0a022 100644 --- a/scripts/release-config.sh +++ b/scripts/release-config.sh @@ -1,17 +1,141 @@ #!/usr/bin/env bash +RELEASE_DOMAIN_GRAMMAR='[a-z0-9]+([_-][a-z0-9]+)*' + +release_validate_domain() { + local domain="${1:-}" + + if [[ -z "${domain}" ]]; then + return 0 + fi + # A domain that looks like "rc5" would be indistinguishable from a plain + # release candidate suffix once attached to a tag (vX.Y.Z-rc5). + if [[ "${domain}" =~ ^rc[0-9]+$ ]]; then + return 1 + fi + [[ "${domain}" =~ ^${RELEASE_DOMAIN_GRAMMAR}$ ]] +} + +release_branch_prefix_from_domain() { + local domain="${1:-}" + + release_validate_domain "${domain}" || return 1 + if [[ -z "${domain}" ]]; then + printf 'release/\n' + else + printf 'release/%s/\n' "${domain}" + fi +} + +release_effective_branch_prefix() { + local domain="${1:-}" + + release_branch_prefix_from_domain "${domain}" +} + +release_tag_prefix_from_ref_prefix() { + local ref_prefix="${1:-}" + + printf '%sv\n' "${ref_prefix}" +} + +release_branch_prefix_from_ref_prefix() { + local ref_prefix="${1:-}" + + printf '%srelease/\n' "${ref_prefix}" +} + +# Domain-scoped tags carry the domain as a semver pre-release identifier +# (vX.Y.Z-domain, vX.Y.Z-domain.rcN) so the raw tag stays valid semver, unlike +# a path-style domain/vX.Y.Z prefix. +release_tag_version_for_domain() { + local tag="$1" + local domain="${2:-}" + + release_validate_domain "${domain}" || return 1 + + if [[ -z "${domain}" ]]; then + [[ "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)(-rc[0-9]+)?$ ]] || return 1 + printf '%s%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + return 0 + fi + + [[ "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-(${RELEASE_DOMAIN_GRAMMAR})(\.rc([0-9]+))?$ ]] || return 1 + [[ "${BASH_REMATCH[2]}" == "${domain}" ]] || return 1 + + if [[ -n "${BASH_REMATCH[5]}" ]]; then + printf '%s-rc%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[5]}" + else + printf '%s\n' "${BASH_REMATCH[1]}" + fi +} + +# Same suffix grammar as release_tag_version_for_domain, for callers (Docker +# builds) that must recover the domain from a tag without already knowing it. +# A bare "-rcN" is always treated as a domain-less release candidate; domain +# names matching rc[0-9]+ are rejected by release_validate_domain to keep that +# unambiguous. +release_docker_parse_tag() { + local tag="$1" + local core domain rc + + if [[ "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-rc([0-9]+)$ ]]; then + core="${BASH_REMATCH[1]}"; domain=""; rc="${BASH_REMATCH[2]}" + elif [[ "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-(${RELEASE_DOMAIN_GRAMMAR})\.rc([0-9]+)$ ]]; then + core="${BASH_REMATCH[1]}"; domain="${BASH_REMATCH[2]}"; rc="${BASH_REMATCH[4]}" + elif [[ "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-(${RELEASE_DOMAIN_GRAMMAR})$ ]]; then + core="${BASH_REMATCH[1]}"; domain="${BASH_REMATCH[2]}"; rc="" + elif [[ "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + core="${BASH_REMATCH[1]}"; domain=""; rc="" + else + return 1 + fi + + release_validate_domain "${domain}" || return 1 + + printf '%s|%s|%s\n' "${core}" "${domain}" "${rc}" +} + release_docker_image_version_from_tag() { local tag="$1" - local version + local core domain rc image_version + + IFS='|' read -r core domain rc < <(release_docker_parse_tag "${tag}") || return 1 + + image_version="${core}" + [[ -n "${rc}" ]] && image_version="${image_version}-rc${rc}" - if [[ ! "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?)$ ]]; then + if [[ ! "${image_version}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then return 1 fi - version="${BASH_REMATCH[1]}" - if [[ ! "${version}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + printf '%s\n' "${image_version}" +} + +release_docker_image_name_prefix_from_tag() { + local tag="$1" + local core domain rc + + IFS='|' read -r core domain rc < <(release_docker_parse_tag "${tag}") || return 1 + + if [[ -n "${domain}" ]]; then + printf '%s-\n' "${domain}" + else + printf '\n' + fi +} + +release_docker_image_metadata_from_tag() { + local tag="$1" + local image_version image_name_prefix + + if ! image_version="$(release_docker_image_version_from_tag "${tag}")"; then + return 1 + fi + if ! image_name_prefix="$(release_docker_image_name_prefix_from_tag "${tag}")"; then return 1 fi - printf '%s\n' "${version}" + printf 'image_version=%s\n' "${image_version}" + printf 'image_name_prefix=%s\n' "${image_name_prefix}" } diff --git a/scripts/release-package.sh b/scripts/release-package.sh index 831bce77..6974c546 100755 --- a/scripts/release-package.sh +++ b/scripts/release-package.sh @@ -7,11 +7,12 @@ set -euo pipefail TAG="${1:?Usage: release-package.sh [TARGET]}" TARGET="${2:-$(rustc -vV | awk '/^host:/ {print $2}')}" +ASSET_TAG="${TAG//\//-}" BINARIES=(arc-node-execution arc-node-consensus arc-snapshots) BUILD_DIR="target/release" OUT_DIR="release-assets" -ARCHIVE_NAME="arc-node-${TAG}-${TARGET}.tar.gz" +ARCHIVE_NAME="arc-node-${ASSET_TAG}-${TARGET}.tar.gz" mkdir -p "$OUT_DIR" diff --git a/scripts/release-refs.sh b/scripts/release-refs.sh new file mode 100755 index 00000000..e780c131 --- /dev/null +++ b/scripts/release-refs.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + release-refs.sh --tag vX.Y.Z[-rcN] [--release-ref-prefix test-] + +Outputs shell-style key=value metadata for the release tag. +USAGE +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/release-config.sh +source "${SCRIPT_DIR}/release-config.sh" + +TAG="" +RELEASE_REF_PREFIX="${RELEASE_REF_PREFIX:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) TAG="${2:?missing tag}"; shift 2 ;; + --release-ref-prefix) RELEASE_REF_PREFIX="${2-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ -z "${TAG}" ]]; then + echo "Set --tag" >&2 + exit 1 +fi + +TAG_PREFIX="$(release_tag_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" +RELEASE_BRANCH_PREFIX="$(release_branch_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" + +if [[ -z "${TAG_PREFIX}" ]]; then + echo "tag prefix cannot be empty" >&2 + exit 1 +fi +if [[ -z "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "release branch prefix cannot be empty" >&2 + exit 1 +fi +if ! git check-ref-format "refs/tags/${TAG_PREFIX}0.0.0" >/dev/null 2>&1; then + echo "Invalid tag prefix: ${TAG_PREFIX}" >&2 + exit 1 +fi +if ! git check-ref-format "refs/heads/${RELEASE_BRANCH_PREFIX}0.0" >/dev/null 2>&1; then + echo "Invalid release branch prefix: ${RELEASE_BRANCH_PREFIX}" >&2 + exit 1 +fi + +if [[ "${TAG:0:${#TAG_PREFIX}}" != "${TAG_PREFIX}" ]]; then + echo "Invalid release tag: ${TAG} does not start with ${TAG_PREFIX}" >&2 + exit 1 +fi + +VERSION="${TAG:${#TAG_PREFIX}}" +if [[ ! "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc([0-9]+))?$ ]]; then + echo "Invalid release tag: ${TAG}" >&2 + exit 1 +fi + +MAJOR="${BASH_REMATCH[1]}" +MINOR="${BASH_REMATCH[2]}" +PATCH="${BASH_REMATCH[3]}" +RC="${BASH_REMATCH[5]:-}" +IS_RELEASE_CANDIDATE=false +if [[ -n "${RC}" ]]; then + IS_RELEASE_CANDIDATE=true +fi +RELEASE_BRANCH="${RELEASE_BRANCH_PREFIX}${MAJOR}.${MINOR}" +COPYBARA_BRANCH_VERSION="${TAG//\//-}" +COPYBARA_BRANCH_VERSION="${COPYBARA_BRANCH_VERSION//./_}" + +{ + echo "tag=${TAG}" + echo "version=${VERSION}" + echo "major=${MAJOR}" + echo "minor=${MINOR}" + echo "patch=${PATCH}" + echo "rc=${RC}" + echo "is_release_candidate=${IS_RELEASE_CANDIDATE}" + echo "release_branch=${RELEASE_BRANCH}" + echo "copybara_pr_branch=sync/${COPYBARA_BRANCH_VERSION}" +} diff --git a/scripts/saturation_report.py b/scripts/saturation_report.py new file mode 100644 index 00000000..3d6e544a --- /dev/null +++ b/scripts/saturation_report.py @@ -0,0 +1,1349 @@ +#!/usr/bin/env python3 +""" +Generate a self-contained HTML report from a quake saturation experiment. + +Reads /experiment.json and per-phase tx_latency_*.csv files, +then writes /report.html. + +Usage: + python3 scripts/saturation_report.py + +Dependencies: matplotlib, jinja2 (pip install matplotlib jinja2). +""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import io +import json +import sys +import tarfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.patches as mpatches + import matplotlib.pyplot as plt +except ImportError: + print("error: matplotlib is required — install with: pip install matplotlib", file=sys.stderr) + sys.exit(1) + +try: + from jinja2 import Environment +except ImportError: + print("error: jinja2 is required — install with: pip install jinja2", file=sys.stderr) + sys.exit(1) + +# ── Signal metadata ─────────────────────────────────────────────────────────── + +SIGNAL_ORDER = [ + "gas_plateaued", + "tps_plateaued", + "tps_ratio_drop", + "latency_spike", + "mempool_growth", + "el_cpu_saturated", +] + +SIGNAL_LABELS = { + "gas_plateaued": "Gas Plateaued", + "tps_plateaued": "TPS Plateaued", + "tps_ratio_drop": "TPS Ratio Drop", + "latency_spike": "Latency Spike", + "mempool_growth": "Mempool Growth", + "el_cpu_saturated": "EL CPU Saturated", +} + +# ── Palette ─────────────────────────────────────────────────────────────────── + +C_ACTUAL = "#2196F3" +C_OFFERED = "#90CAF9" +C_FILL = "#FF9800" +C_GAS = "#4CAF50" +C_P50 = "#9C27B0" +C_P95 = "#F44336" +C_MEMPOOL = "#009688" +C_SAT_LINE = "#F44336" +C_SIG_ON = "#F44336" +C_SIG_OFF = "#E8F5E9" + +# ── Data loading ────────────────────────────────────────────────────────────── + +def load_experiment(experiment_dir: Path) -> dict: + path = experiment_dir / "experiment.json" + if not path.exists(): + print(f"error: experiment.json not found in {experiment_dir}", file=sys.stderr) + sys.exit(1) + with open(path) as f: + return json.load(f) + + +def find_latest_csv(phase_dir: Path) -> Optional[Path]: + if not phase_dir.exists(): + return None + candidates = sorted( + (p for p in phase_dir.iterdir() if p.name.startswith("tx_latency_") and p.name.endswith(".csv")), + key=lambda p: p.stat().st_mtime, + ) + return candidates[-1] if candidates else None + + +def read_latency_ms(csv_path: Path) -> list[float]: + """Parse submitted_at and finalized_observed_at columns and return latencies in ms.""" + latencies: list[float] = [] + with open(csv_path, newline="") as f: + reader = csv.reader(f) + for i, row in enumerate(reader): + if i == 0: + continue + if len(row) < 3: + continue + try: + t_sub = _parse_rfc3339(row[1].strip()) + t_fin = _parse_rfc3339(row[2].strip()) + if t_sub is None or t_fin is None: + continue + diff_ms = (t_fin - t_sub).total_seconds() * 1000 + if diff_ms >= 0: + latencies.append(diff_ms) + except (ValueError, IndexError): + continue + return latencies + + +def load_metrics_tarball(experiment_dir: Path) -> dict[str, list[dict]]: + """Load non-empty Prometheus series from metrics.tar.gz. + + Returns a dict mapping metric_name → list of series (each with 'metric' labels and 'values'). + Returns an empty dict if the tarball is absent, unreadable, or all series are empty. + """ + tar_path = experiment_dir / "metrics.tar.gz" + if not tar_path.exists(): + return {} + result: dict[str, list[dict]] = {} + try: + with tarfile.open(tar_path, "r:gz") as tar: + for member in tar.getmembers(): + if not member.name.endswith(".json"): + continue + metric_name = Path(member.name).stem + f = tar.extractfile(member) + if f is None: + continue + data = json.load(f) + series = data.get("data", {}).get("result", []) + if series: + result[metric_name] = series + except Exception as e: + print(f"warning: could not read metrics.tar.gz: {e}", file=sys.stderr) + return result + + +def _parse_rfc3339(s: str) -> Optional[datetime]: + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + return datetime.fromisoformat(s) + except ValueError: + return None + +# ── Saturation detection ────────────────────────────────────────────────────── + +def find_saturation_phase(phases: list[dict]) -> Optional[dict]: + """First phase with ≥2 signals; falls back to first phase with a critical signal.""" + for phase in phases: + if len(phase.get("signals", [])) >= 2: + return phase + critical = {"gas_plateaued", "tps_ratio_drop"} + for phase in phases: + if any(s in critical for s in phase.get("signals", [])): + return phase + return None + + +def saturation_index(phases: list[dict], sat_phase: Optional[dict]) -> Optional[int]: + if sat_phase is None: + return None + for i, p in enumerate(phases): + if p is sat_phase: + return i + return None + +# ── Chart helpers ───────────────────────────────────────────────────────────── + +def _fig_to_base64(fig) -> str: + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") + plt.close(fig) + return base64.b64encode(buf.getvalue()).decode() + + +def _phase_labels(phases: list[dict]) -> list[str]: + return [str(p["offered_tps"]) for p in phases] + + +def _annotate_saturation(ax, sat_idx: Optional[int]) -> None: + if sat_idx is not None: + ax.axvline(x=sat_idx, color=C_SAT_LINE, linestyle="--", linewidth=1.5, alpha=0.7, label="Saturation") + +# ── Charts ──────────────────────────────────────────────────────────────────── + +def chart_throughput(phases: list[dict], sat_idx: Optional[int]) -> str: + xs = list(range(len(phases))) + labels = _phase_labels(phases) + actual = [p["metrics"].get("actual_tps") for p in phases] + offered = [p["offered_tps"] for p in phases] + fill = [p["metrics"].get("fill_pct") for p in phases] + + fig, ax1 = plt.subplots(figsize=(9, 4)) + ax2 = ax1.twinx() + + ax1.bar(xs, [v or 0 for v in actual], color=C_ACTUAL, alpha=0.8, label="Actual TPS", zorder=3) + ax1.plot(xs, offered, color=C_OFFERED, linestyle="--", marker="o", markersize=5, label="Offered TPS", zorder=4) + ax2.plot(xs, [v or 0 for v in fill], color=C_FILL, linestyle="-", marker="s", markersize=5, label="Fill %", zorder=4) + + _annotate_saturation(ax1, sat_idx) + ax1.set_xticks(xs) + ax1.set_xticklabels(labels) + ax1.set_xlabel("Offered TPS") + ax1.set_ylabel("TPS") + ax2.set_ylabel("Fill %") + ax2.set_ylim(0, 115) + lines1, labs1 = ax1.get_legend_handles_labels() + lines2, labs2 = ax2.get_legend_handles_labels() + ax1.legend(lines1 + lines2, labs1 + labs2, loc="upper left", fontsize=8) + ax1.set_title("Throughput per Phase") + ax1.grid(axis="y", alpha=0.3) + fig.tight_layout() + return _fig_to_base64(fig) + + +def chart_gas(phases: list[dict], sat_idx: Optional[int]) -> str: + xs = list(range(len(phases))) + labels = _phase_labels(phases) + gas_m = [(p["metrics"].get("gas_per_sec") or 0) / 1_000_000 for p in phases] + + fig, ax = plt.subplots(figsize=(9, 3.5)) + ax.bar(xs, gas_m, color=C_GAS, alpha=0.8) + _annotate_saturation(ax, sat_idx) + ax.set_xticks(xs) + ax.set_xticklabels(labels) + ax.set_xlabel("Offered TPS") + ax.set_ylabel("Gas/s (millions)") + ax.set_title("Gas Throughput per Phase") + ax.grid(axis="y", alpha=0.3) + fig.tight_layout() + return _fig_to_base64(fig) + + +def chart_latency(phases: list[dict], sat_idx: Optional[int]) -> str: + xs = list(range(len(phases))) + labels = _phase_labels(phases) + p50 = [p["metrics"].get("latency_p50_ms") for p in phases] + p95 = [p["metrics"].get("latency_p95_ms") for p in phases] + + fig, ax = plt.subplots(figsize=(9, 3.5)) + xs_50 = [x for x, v in zip(xs, p50) if v is not None] + xs_95 = [x for x, v in zip(xs, p95) if v is not None] + ax.plot(xs_50, [v for v in p50 if v is not None], color=C_P50, marker="o", label="p50") + ax.plot(xs_95, [v for v in p95 if v is not None], color=C_P95, marker="s", label="p95") + _annotate_saturation(ax, sat_idx) + ax.set_xticks(xs) + ax.set_xticklabels(labels) + ax.set_xlabel("Offered TPS") + ax.set_ylabel("Latency (ms)") + ax.set_title("Submit-to-Finalized Latency per Phase") + ax.legend(fontsize=8) + ax.grid(alpha=0.3) + fig.tight_layout() + return _fig_to_base64(fig) + + +C_QUEUED = "#FF5722" +C_BASEFEE = "#9C27B0" + +def chart_mempool(phases: list[dict], sat_idx: Optional[int]) -> str: + """Stacked sub-pool counts + per-pool size in MB on a paired axis. + + Each sub-pool's peak count gets a bar at the left, the size in MB its + twin on the right. Two axes share the X (offered-TPS labels) so visual + ordering of phases stays consistent. Basefee is included because it + is the sub-pool that silently swallowed legacy txs at saturation in + earlier 50k-account runs. + """ + xs = list(range(len(phases))) + labels = _phase_labels(phases) + + pk_pend = [p["metrics"].get("max_mempool") or 0 for p in phases] + pk_qued = [p["metrics"].get("max_queued_mempool") or 0 for p in phases] + pk_base = [p["metrics"].get("max_basefee_mempool") or 0 for p in phases] + + pk_pend_mb = [p["metrics"].get("max_pending_size_mb") or 0 for p in phases] + pk_qued_mb = [p["metrics"].get("max_queued_size_mb") or 0 for p in phases] + pk_base_mb = [p["metrics"].get("max_basefee_size_mb") or 0 for p in phases] + + has_qued = any(v > 0 for v in pk_qued) + has_base = any(v > 0 for v in pk_base) + + fig, (ax_count, ax_size) = plt.subplots(1, 2, figsize=(13, 3.5)) + + # Left: peak counts, stacked pending → queued → basefee. + ax_count.bar(xs, pk_pend, color=C_MEMPOOL, alpha=0.7, label="Pending") + bottom = list(pk_pend) + if has_qued: + ax_count.bar(xs, pk_qued, bottom=bottom, color=C_QUEUED, alpha=0.7, label="Queued (future nonce)") + bottom = [b + q for b, q in zip(bottom, pk_qued)] + if has_base: + ax_count.bar(xs, pk_base, bottom=bottom, color=C_BASEFEE, alpha=0.7, label="Basefee") + _annotate_saturation(ax_count, sat_idx) + ax_count.set_xticks(xs); ax_count.set_xticklabels(labels) + ax_count.set_xlabel("Offered TPS"); ax_count.set_ylabel("Transactions") + ax_count.set_title("Peak sub-pool depth — count") + ax_count.legend(fontsize=8) + ax_count.grid(axis="y", alpha=0.3) + + # Right: peak sizes in MB, same stacking colours so each pool reads as + # the same "track" across both panels. + ax_size.bar(xs, pk_pend_mb, color=C_MEMPOOL, alpha=0.7, label="Pending") + bottom = list(pk_pend_mb) + if has_qued: + ax_size.bar(xs, pk_qued_mb, bottom=bottom, color=C_QUEUED, alpha=0.7, label="Queued") + bottom = [b + q for b, q in zip(bottom, pk_qued_mb)] + if has_base: + ax_size.bar(xs, pk_base_mb, bottom=bottom, color=C_BASEFEE, alpha=0.7, label="Basefee") + _annotate_saturation(ax_size, sat_idx) + ax_size.set_xticks(xs); ax_size.set_xticklabels(labels) + ax_size.set_xlabel("Offered TPS"); ax_size.set_ylabel("Cumulative MB") + ax_size.set_title("Peak sub-pool depth — size (MB)") + ax_size.legend(fontsize=8) + ax_size.grid(axis="y", alpha=0.3) + + fig.tight_layout() + return _fig_to_base64(fig) + + +def chart_signals(phases: list[dict]) -> str: + n_phases = len(phases) + n_sigs = len(SIGNAL_ORDER) + + fig, ax = plt.subplots(figsize=(max(6, n_phases * 1.4), 3)) + + for j, phase in enumerate(phases): + fired = set(phase.get("signals", [])) + for i, sig in enumerate(SIGNAL_ORDER): + color = C_SIG_ON if sig in fired else C_SIG_OFF + ax.add_patch(plt.Rectangle((j - 0.5, i - 0.5), 1, 1, color=color, linewidth=0.5, edgecolor="#ccc")) + + ax.set_xlim(-0.5, n_phases - 0.5) + ax.set_ylim(-0.5, n_sigs - 0.5) + ax.set_xticks(range(n_phases)) + ax.set_xticklabels(_phase_labels(phases)) + ax.set_yticks(range(n_sigs)) + ax.set_yticklabels([SIGNAL_LABELS[s] for s in SIGNAL_ORDER]) + ax.set_xlabel("Offered TPS") + ax.set_title("Saturation Signals by Phase") + ax.invert_yaxis() + legend = [ + mpatches.Patch(color=C_SIG_ON, label="Fired"), + mpatches.Patch(color=C_SIG_OFF, label="Clear", edgecolor="#aaa"), + ] + ax.legend(handles=legend, loc="upper right", fontsize=8) + fig.tight_layout() + return _fig_to_base64(fig) + + +def chart_memory(phases: list[dict], sat_idx: Optional[int]) -> Optional[str]: + xs = list(range(len(phases))) + labels = _phase_labels(phases) + avg_mb = [p["metrics"].get("el_mem_avg_mb") for p in phases] + peak_mb = [p["metrics"].get("el_mem_peak_mb") for p in phases] + + if all(v is None for v in avg_mb) and all(v is None for v in peak_mb): + return None + + fig, ax = plt.subplots(figsize=(9, 3.5)) + xs_a = [x for x, v in zip(xs, avg_mb) if v is not None] + xs_p = [x for x, v in zip(xs, peak_mb) if v is not None] + if xs_a: + ax.bar(xs_a, [v for v in avg_mb if v is not None], color="#42A5F5", alpha=0.8, label="Avg (MiB)") + if xs_p: + ax.plot(xs_p, [v for v in peak_mb if v is not None], + color="#1565C0", marker="^", linewidth=1.5, linestyle="--", label="Peak (MiB)") + _annotate_saturation(ax, sat_idx) + ax.set_xticks(xs) + ax.set_xticklabels(labels) + ax.set_xlabel("Offered TPS") + ax.set_ylabel("Resident Memory (MiB)") + ax.set_title("EL Resident Memory per Phase") + ax.legend(fontsize=8) + ax.grid(axis="y", alpha=0.3) + fig.tight_layout() + return _fig_to_base64(fig) + + +def chart_pool_evictions_prom( + prom_metrics: dict, + exp_start: Optional[datetime], + phase_starts: list[tuple[float, int]], +) -> Optional[str]: + """Rate of evictions per sub-pool (pending, basefee, blob, queued), summed across nodes.""" + any_data = any(prom_metrics.get(m) for m, _, _ in _EVICTION_METRICS) + if not any_data: + return None + + fig, ax = plt.subplots(figsize=(9, 3.5)) + for metric_name, label, color in _EVICTION_METRICS: + series = prom_metrics.get(metric_name) + if not series: + continue + summed: dict[float, float] = {} + for s in series: + xs, ys = _series_to_xy(s, exp_start) + rx, ry = _compute_rate(xs, ys) + for t, r in zip(rx, ry): + k = round(t, 3) + summed[k] = summed.get(k, 0.0) + r + if summed: + pts = sorted(summed.items()) + ax.plot([p[0] for p in pts], [p[1] for p in pts], + marker=".", markersize=2, linewidth=1, label=label, color=color) + + for t_min, rate in phase_starts: + ax.axvline(x=t_min, color="#bbb", linestyle=":", linewidth=1) + ax.text(t_min + 0.05, 0.98, f"{rate}", fontsize=6, color="#666", va="top", + transform=ax.get_xaxis_transform()) + + ax.set_xlabel("Time (min from start)" if exp_start is not None else "Time (min)") + ax.set_ylabel("Evictions/s") + ax.set_title("Pool Evictions by Sub-pool (rate, summed across nodes)") + ax.legend(fontsize=8) + ax.grid(alpha=0.3) + fig.tight_layout() + return _fig_to_base64(fig) + + +def chart_memory_prom( + prom_metrics: dict, + exp_start: Optional[datetime], + phase_starts: list[tuple[float, int]], + node_colors: Optional[dict[str, tuple]] = None, +) -> Optional[str]: + """EL resident memory over time from the Prometheus tarball (MiB, per node).""" + series = prom_metrics.get("reth_process_resident_memory_bytes") + if not series: + return None + + mib = 1_048_576.0 + fig, ax = plt.subplots(figsize=(9, 3.5)) + for s in series: + node = _extract_node_label(s.get("metric", {})) + xs, ys = _series_to_xy(s, exp_start) + ys_mib = [y / mib for y in ys] + if xs: + color = node_colors.get(node) if node_colors else None + ax.plot(xs, ys_mib, marker=".", markersize=2, linewidth=1, label=node, color=color) + + for t_min, rate in phase_starts: + ax.axvline(x=t_min, color="#bbb", linestyle=":", linewidth=1) + ax.text(t_min + 0.05, 0.98, f"{rate}", fontsize=6, color="#666", va="top", + transform=ax.get_xaxis_transform()) + + ax.set_xlabel("Time (min from start)" if exp_start is not None else "Time (min)") + ax.set_ylabel("Resident Memory (MiB)") + ax.set_title("EL Resident Memory (MiB)") + if node_colors is None and len(series) <= 10: + ax.legend(fontsize=7, loc="upper right") + ax.grid(alpha=0.3) + fig.tight_layout() + return _fig_to_base64(fig) + + +def chart_block_timing(phases: list[dict], sat_idx: Optional[int]) -> str: + xs = list(range(len(phases))) + labels = _phase_labels(phases) + build = [p["metrics"].get("avg_block_build_time_ms") for p in phases] + finalize = [p["metrics"].get("avg_block_finalize_time_ms") for p in phases] + + fig, ax = plt.subplots(figsize=(9, 3.5)) + xs_b = [x for x, v in zip(xs, build) if v is not None] + xs_f = [x for x, v in zip(xs, finalize) if v is not None] + if xs_b: + ax.plot(xs_b, [v for v in build if v is not None], + color="#1976D2", marker="o", label="Build time (ms)") + if xs_f: + ax.plot(xs_f, [v for v in finalize if v is not None], + color="#E65100", marker="s", linestyle="--", label="Finalize time (ms)") + _annotate_saturation(ax, sat_idx) + ax.set_xticks(xs) + ax.set_xticklabels(labels) + ax.set_xlabel("Offered TPS") + ax.set_ylabel("Time (ms)") + ax.set_title("Block Build & Finalize Time per Phase") + ax.legend(fontsize=8) + ax.grid(alpha=0.3) + fig.tight_layout() + return _fig_to_base64(fig) + + +def chart_latency_dist(experiment_dir: Path, phases: list[dict]) -> list[tuple[int, str]]: + """Per-phase latency histograms, one standalone image per phase. + + Rendered separately (rather than as a single row of subplots) so a + reader can click any histogram in the report and enlarge it on its + own instead of blowing up all phases at once. Returns an ordered list + of ``(offered_tps, base64_png)`` pairs; empty list if no CSV data. + """ + charts: list[tuple[int, str]] = [] + for phase in phases: + rate = phase["offered_tps"] + csv_path = find_latest_csv(experiment_dir / f"phase_{rate}") + if not csv_path: + continue + lats = read_latency_ms(csv_path) + if not lats: + continue + + fig, ax = plt.subplots(figsize=(4.5, 3.2)) + ax.hist(lats, bins=40, color=C_ACTUAL, alpha=0.8, edgecolor="white", linewidth=0.3) + ax.set_title(f"{rate} TPS") + ax.set_xlabel("Latency (ms)") + ax.set_ylabel("Transactions") + ax.grid(alpha=0.3, axis="y") + fig.tight_layout() + charts.append((rate, _fig_to_base64(fig))) + + return charts + +# Prometheus chart config. +# +# Each entry is (metric_name, title, ylabel, mode) where mode is: +# "rate" — compute per-second rate from a cumulative counter/histogram _sum +# "avg" — compute per-block average: Δsum / Δcount, requires a paired +# "_count" series in the tarball +# "raw" — plot the raw cumulative value as-is +_PROM_CHART_CONFIG = [ + ("arc_malachite_app_block_time_sum", "Block Time (avg)", "Seconds/block", "avg"), + ("arc_malachite_app_block_build_time_sum", "Block Build Time (avg)", "Seconds/block", "avg"), + ("arc_malachite_app_block_finalize_time_sum", "Block Finalize Time (avg)", "Seconds/block", "avg"), + ("arc_malachite_app_block_gas_used_sum", "Gas Used (rate)", "Gas/s", "rate"), + ("arc_malachite_app_block_transactions_count_sum", "Block Tx Count (rate)", "Tx/s", "rate"), + ("malachitebft_core_consensus_consensus_round_sum", "Consensus Round (avg)", "Round/block", "avg"), + ("malachitebft_core_consensus_consensus_time_sum", "Consensus Time (avg)", "Seconds/block", "avg"), + ("arc_malachite_app_height_restart_count_total", "Height Restart Count", "Count", "raw"), + ("arc_malachite_app_sync_fell_behind_count_total", "Sync Fell Behind Count", "Count", "raw"), + ("reth_process_cpu_seconds_total", "EL CPU Usage (rate)", "CPU cores", "rate"), + # Txpool gauges — pending = executable now, queued = nonce-gapped + ("reth_transaction_pool_pending_pool_transactions", "Txpool Pending", "Transactions", "raw"), + ("reth_transaction_pool_queued_pool_transactions", "Txpool Queued (nonce gap)", "Transactions", "raw"), +] + +# Pool eviction sub-pools, rendered together in one combined chart. +_EVICTION_METRICS = [ + ("reth_transaction_pool_pending_transactions_evicted_total", "Pending", "#2196F3"), + ("reth_transaction_pool_basefee_transactions_evicted_total", "Base fee", "#FF9800"), + ("reth_transaction_pool_blob_transactions_evicted_total", "Blob", "#9C27B0"), + ("reth_transaction_pool_queued_transactions_evicted_total", "Queued (nonce)", "#F44336"), +] + + +def _extract_node_label(metric_labels: dict) -> str: + """Return a clean node name (e.g. ``validator3``) for legends. + + Prefer the Prometheus ``job`` label, which quake's scrape config sets to + ``_el`` / ``_cl``; strip that side suffix so every chart keys + on the raw node name. Fall back to ``node``/``instance`` for older + setups. ``instance`` is IP-based so it's the last resort — avoid it in + legends when a name is available. + """ + for key in ("node", "job"): + v = metric_labels.get(key) + if v: + for suf in ("_el", "_cl"): + if v.endswith(suf): + return v[: -len(suf)] + return v + return metric_labels.get("instance") or str(metric_labels) + + +def _natural_key(name: str) -> list: + """Sort key that orders ``validator2`` before ``validator10``. + + Splits into alternating text/int chunks so trailing digits compare + numerically, keeping node groups (validator, sentry, full, …) clustered + together and ordered by their index within each group. + """ + import re + return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", name)] + + +def _build_node_color_map(series_lists: list[list[dict]]) -> dict[str, tuple]: + """Assign a stable RGBA color to every node name observed across the + Prometheus series. Colors come from ``tab20`` (distinct up to 20 nodes) + or ``hsv`` (continuous, for larger topologies).""" + names: set[str] = set() + for series_list in series_lists: + for s in series_list: + names.add(_extract_node_label(s.get("metric", {}))) + ordered = sorted(names, key=_natural_key) + cmap_name = "tab20" if len(ordered) <= 20 else "hsv" + cmap = plt.get_cmap(cmap_name) + n = len(ordered) + return {name: cmap(i / max(n, 1)) for i, name in enumerate(ordered)} + + +def chart_prom_legend(node_colors: dict[str, tuple]) -> Optional[str]: + """Render a shared color→node-name legend for the Prometheus charts. + + Individual per-node charts hide their legend when the node count is + large (crowded, unreadable); this standalone legend is the reference for + all of them, so every chart uses the same node→color mapping. + """ + if not node_colors: + return None + n = len(node_colors) + # Wider row so each label has room; row count grows with node count. + ncol = min(n, 12) + nrow = (n + ncol - 1) // ncol + fig, ax = plt.subplots(figsize=(18, max(0.3 * nrow + 0.4, 1.0))) + ax.axis("off") + handles = [mpatches.Patch(color=c, label=name) for name, c in node_colors.items()] + ax.legend(handles=handles, loc="center", ncol=ncol, fontsize=9, frameon=False) + fig.tight_layout() + return _fig_to_base64(fig) + + +def _series_to_xy(s: dict, exp_start: Optional[datetime]) -> tuple[list[float], list[float]]: + """Convert a Prometheus series to (x_minutes, y_values) lists.""" + values = s.get("values", []) + timestamps = [float(v[0]) for v in values] + ys: list[float] = [] + for v in values: + try: + ys.append(float(v[1])) + except (ValueError, TypeError): + ys.append(float("nan")) + if exp_start is not None: + t0 = exp_start.timestamp() + xs = [(t - t0) / 60 for t in timestamps] + else: + xs = [t / 60 for t in timestamps] + return xs, ys + + +def _compute_rate(xs: list[float], ys: list[float]) -> tuple[list[float], list[float]]: + """Compute per-second rate from a monotonically-increasing cumulative series. + + Skips intervals where the counter went backwards (``ys[i] < ys[i-1]``) — + those are counter resets from a node restart, and treating them as + negative deltas produces misleading dips on the chart. PromQL's + ``rate()`` handles resets natively; this is the python-side equivalent. + """ + rx, ry = [], [] + for i in range(1, len(xs)): + dt_min = xs[i] - xs[i - 1] + if dt_min <= 0: + continue + d = ys[i] - ys[i - 1] + if d < 0: + # Counter reset (process restart). Drop the point — real Prometheus + # would treat the new value as the post-reset baseline; for a chart + # we just want to avoid plotting a spurious negative spike. + continue + dt_sec = dt_min * 60 + rx.append((xs[i] + xs[i - 1]) / 2) + ry.append(d / dt_sec) + return rx, ry + + +def _compute_avg( + xs_sum: list[float], ys_sum: list[float], + xs_cnt: list[float], ys_cnt: list[float], +) -> tuple[list[float], list[float]]: + """Compute Δsum / Δcount (per-block average) aligned to the _sum timestamps.""" + # Round timestamps to 3 decimal places (1 ms) before keying so that minor + # float representation differences between _sum and _count scrapes don't + # silently drop data points. + _r = lambda t: round(t, 3) + cnt_map = {_r(t): v for t, v in zip(xs_cnt, ys_cnt)} + rx, ry = [], [] + for i in range(1, len(xs_sum)): + d_sum = ys_sum[i] - ys_sum[i - 1] + cnt_prev = cnt_map.get(_r(xs_sum[i - 1])) + cnt_curr = cnt_map.get(_r(xs_sum[i])) + if cnt_prev is None or cnt_curr is None: + continue + d_cnt = cnt_curr - cnt_prev + if d_cnt <= 0: + continue + rx.append((xs_sum[i] + xs_sum[i - 1]) / 2) + ry.append(d_sum / d_cnt) + return rx, ry + + +def chart_prometheus_metric( + series: list[dict], + title: str, + ylabel: str, + mode: str, + exp_start: Optional[datetime], + phase_starts: list[tuple[float, int]], + count_series: Optional[list[dict]] = None, + node_colors: Optional[dict[str, tuple]] = None, +) -> str: + """Time-series line chart for a Prometheus metric, one line per node. + + mode: + "rate" — per-second rate of a cumulative counter/sum + "avg" — Δsum / Δcount per block (requires count_series) + "raw" — plot raw cumulative values + """ + fig, ax = plt.subplots(figsize=(9, 3.5)) + + # Build a node → count_series lookup for "avg" mode + count_by_node: dict[str, list[dict]] = {} + if mode == "avg" and count_series: + for cs in count_series: + node = _extract_node_label(cs.get("metric", {})) + count_by_node[node] = cs + + for s in series: + node = _extract_node_label(s.get("metric", {})) + xs, ys = _series_to_xy(s, exp_start) + if not xs: + continue + + if mode == "rate": + px, py = _compute_rate(xs, ys) + elif mode == "avg" and node in count_by_node: + xs_cnt, ys_cnt = _series_to_xy(count_by_node[node], exp_start) + px, py = _compute_avg(xs, ys, xs_cnt, ys_cnt) + else: + px, py = xs, ys + + if not px: + continue + color = node_colors.get(node) if node_colors else None + ax.plot(px, py, marker=".", markersize=2, linewidth=1, label=node, color=color) + + for t_min, rate in phase_starts: + ax.axvline(x=t_min, color="#bbb", linestyle=":", linewidth=1) + ax.text( + t_min + 0.05, 0.98, f"{rate}", + fontsize=6, color="#666", va="top", + transform=ax.get_xaxis_transform(), + ) + + ax.set_xlabel("Time (min from start)" if exp_start is not None else "Time (min)") + ax.set_ylabel(ylabel) + ax.set_title(title) + ax.grid(alpha=0.3) + if node_colors is None and len(series) <= 10: + ax.legend(fontsize=7, loc="upper right") + fig.tight_layout() + return _fig_to_base64(fig) + + +# ── Formatting helpers ──────────────────────────────────────────────────────── + +def _fmt(v: Optional[float], fmt: str = ".1f", suffix: str = "") -> str: + return f"{v:{fmt}}{suffix}" if v is not None else "—" + + +def _fmt_avg_sd(avg: Optional[float], sd: Optional[float]) -> str: + if avg is None: + return "—" + if sd is None: + return f"{avg:.0f}" + return f"{avg:.0f}±{sd:.0f}" + + +def _fmt_count_mb(count: Optional[float], size_mb: Optional[float]) -> str: + """Render a sub-pool depth cell as ``count(MB)``. + + Reth caps each transaction sub-pool on both transaction count and cumulative + wire size, so the experiment runner emits both. Show them in the same cell + so a reader can spot which dimension is binding without cross-referencing + columns. Either side renders as ``—`` when missing, keeping the layout + legible against older ``experiment.json`` files that lack the size fields. + """ + if count is None and size_mb is None: + return "—" + c = f"{count:.0f}" if count is not None else "—" + s = f"{size_mb:.1f}" if size_mb is not None else "—" + return f"{c} ({s})" + + +def _fmt_gas(v: Optional[float]) -> str: + if v is None: + return "—" + if v >= 1_000_000: + return f"{v / 1_000_000:.1f}M" + if v >= 1_000: + return f"{v / 1_000:.1f}K" + return f"{v:.0f}" + + +def _fmt_duration(seconds: float) -> str: + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = int(seconds % 60) + if h > 0: + return f"{h}h {m:02d}m" + if m > 0: + return f"{m}m {s:02d}s" + return f"{s}s" + + +def _fmt_node_disk(topology: dict) -> str: + """Render the node disk summary as ` GiB / / IOPS`.""" + size = topology.get("node_disk_gb") + vol_type = topology.get("node_volume_type") + iops = topology.get("node_volume_iops") + parts = [] + if size is not None: + parts.append(f"{size} GiB") + if vol_type: + parts.append(vol_type) + if iops is not None: + parts.append(f"{iops} IOPS") + return " / ".join(parts) if parts else "—" + + +def _leading_alpha_prefix(name: str) -> str: + """Strip the trailing non-alphabetic segment of a node name. + + `validator-blue` → `validator`, `sentry-us-east-2-a` → `sentry`, + `full-1` → `full`. Falls back to the whole name when no alphabetic + prefix is present. + """ + chars = [] + for c in name: + if c.isalpha(): + chars.append(c) + else: + break + return "".join(chars) if chars else name + + +def _topology_from_manifest(manifest: dict) -> dict: + """Derive the topology summary fields from the parsed manifest TOML. + + The saturation runner now embeds the full manifest under + `parameters.manifest` instead of a hand-picked Topology struct; this + function reconstructs the same summary fields the HTML template expects + so the topology table keeps rendering without a separate schema. + """ + nodes = manifest.get("nodes") or {} + node_names = list(nodes.keys()) + + nodes_by_type: dict[str, int] = {} + for name in node_names: + t = _leading_alpha_prefix(name) + nodes_by_type[t] = nodes_by_type.get(t, 0) + 1 + nodes_by_type = dict(sorted(nodes_by_type.items())) + + num_validators = sum(1 for n in node_names if n.startswith("validator")) + + el_storage_v2 = True + if node_names: + first = nodes[node_names[0]] + v2 = (first.get("el") or {}).get("config", {}).get("storage", {}).get("v2") + if v2 is not None: + el_storage_v2 = bool(v2) + + return { + "num_nodes": len(node_names), + "num_validators": num_validators, + "nodes_by_type": nodes_by_type, + "node_size": manifest.get("node_size"), + "node_disk_gb": manifest.get("node_disk_gb"), + "node_volume_type": manifest.get("node_volume_type"), + "node_volume_iops": manifest.get("node_volume_iops"), + "el_cpu_limit": manifest.get("el_cpu_limit"), + "el_memory_limit_gb": manifest.get("el_memory_limit_gb"), + "cl_cpu_limit": manifest.get("cl_cpu_limit"), + "cl_memory_limit_gb": manifest.get("cl_memory_limit_gb"), + "extra_account_balance_usdc": manifest.get("extra_account_balance_usdc"), + "block_gas_limit": manifest.get("block_gas_limit"), + "cc_size": manifest.get("cc_size"), + "cc_disk_gb": manifest.get("cc_disk_gb"), + "image_el": manifest.get("image_el"), + "image_cl": manifest.get("image_cl"), + "el_storage_v2": el_storage_v2, + } + + +def _fmt_container_resources(cpu: Optional[float], mem_gb: Optional[float]) -> str: + """Render per-container resource caps as ` CPU / GiB`.""" + parts = [] + if cpu is not None: + parts.append(f"{cpu:g} CPU") + if mem_gb is not None: + parts.append(f"{mem_gb:g} GiB") + return " / ".join(parts) if parts else "—" + + +def _fmt_usdc(v: Optional[int]) -> str: + return f"{v:,} USDC" if v is not None else "—" + + +def _fmt_node_types(by_type: dict) -> str: + """`13 sentry, 36 full, 21 validator` — sorted by count descending.""" + if not by_type: + return "" + items = sorted(by_type.items(), key=lambda kv: (-kv[1], kv[0])) + return ", ".join(f"{count} {name}" for name, count in items) + +# ── HTML template ───────────────────────────────────────────────────────────── + +_TEMPLATE = """\ + + + + + + Saturation Report — {{ experiment_id }} + + + +
+

Saturation Experiment Report

+

+ {{ experiment_id }}  ·  + {{ status_type_display }} + {%- if status_reason %} — {{ status_reason }}{% endif %} +  ·  {{ started_str }} + {%- if ended_str %} → {{ ended_str }}{% endif %} + {%- if duration %}  ·  {{ duration }}{% endif %} +

+ +

Parameters

+ + + + + + + + + +
Rates (TPS){{ params_rates }}
Hold{{ params_hold }}
Ramp-up{{ params_warmup }}
Cooldown{{ params_cooldown }}
Generators{{ params_generators }}
Tx mix{{ params_tx_mix }}
Guzzler weights{{ params_guzzler_fn_weights }}
ERC20 weights{{ params_erc20_fn_weights }}
+ +

Topology & Hardware

+ + + + + + + + + + + + + + +
Nodes{{ topo_num_nodes }}{% if topo_node_types %} ({{ topo_node_types }}){% endif %}
Validators{{ topo_num_validators }}
Node instance{% if topo_node_size %}{{ topo_node_size }}{% else %}—{% endif %}
Node disk{{ topo_node_disk }}
EL resources{{ topo_el_resources }}
CL resources{{ topo_cl_resources }}
CC instance{% if topo_cc_size %}{{ topo_cc_size }}{% else %}—{% endif %}
CC disk{% if topo_cc_disk_gb %}{{ topo_cc_disk_gb }} GiB{% else %}—{% endif %}
Prefund / account{{ topo_account_balance }}
Block gas limit{{ topo_block_gas_limit }}
EL image{% if topo_image_el %}{{ topo_image_el }}{% else %}—{% endif %}
CL image{% if topo_image_cl %}{{ topo_image_cl }}{% else %}—{% endif %}
EL storage{{ topo_el_storage }}
+ +{% if manifest_json %} +

Manifest

+
manifest.json +
{{ manifest_json }}
+
+{% endif %} + +

Saturation Point

+{% if sat_tps is not none %} +
+ Saturation detected at {{ sat_tps }} TPS
+ Signals: {% for label in sat_signal_labels %}{{ label }} {% endfor %} +
+{% else %} +
+ No saturation detected within the tested rate range. + Consider extending --rates to higher values. +
+{% endif %} + +

Phase Summary

+
+ + + + + + + + + + + + + + + + + + {% for phase in phases %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
Rate (TPS)Offered TPS
(spammer local)
Offered B/s
(spammer local)
Actual TPS
(chain)
Gas/sFill %Blk TimeBuild (ms)Finalize (ms)Avg±SD (ms)P50 (ms)P95 (ms)MemAvg (MiB)MemPk (MiB)Peak Pend
count (MB)
Avg Pend
count (MB)
Peak Qued
count (MB)
Avg Qued
count (MB)
Peak BaseF
count (MB)
Avg BaseF
count (MB)
PoolEvctEL CPU avgEL CPU maxCL CPU avgCL CPU maxSignals
{{ phase.offered_tps }}{{ phase.actual_offered_tps }}{{ phase.actual_offered_bytes_per_sec }}{{ phase.actual_tps }}{{ phase.gas_per_sec }}{{ phase.fill_pct }}{{ phase.avg_block_time_s }}{{ phase.avg_block_build_time_ms }}{{ phase.avg_block_finalize_time_ms }}{{ phase.latency_avg_sd_ms }}{{ phase.latency_p50_ms }}{{ phase.latency_p95_ms }}{{ phase.el_mem_avg_mb }}{{ phase.el_mem_peak_mb }}{{ phase.peak_pending }}{{ phase.avg_pending }}{{ phase.peak_queued }}{{ phase.avg_queued }}{{ phase.peak_basefee }}{{ phase.avg_basefee }}{{ phase.pool_evictions }}{{ phase.el_cpu_avg_pct }}{{ phase.el_cpu_max_pct }}{{ phase.cl_cpu_avg_pct }}{{ phase.cl_cpu_max_pct }} + {%- if phase.signal_labels %} + {%- for label in phase.signal_labels %}{{ label }}{% endfor %} + {%- else %}—{% endif %} +
+
+ +{% set phases_with_errors = phases | selectattr("rpc_errors") | list %} +{% if phases_with_errors %} +
RPC Errors by Phase +
+ + + + + + {% for phase in phases %}{% for err, count in phase.rpc_errors %} + + {% if loop.first %}{% endif %} + + + + {% endfor %}{% endfor %} + +
Rate (TPS)ErrorCount
{{ phase.offered_tps }}{{ err }}{{ count }}
+
+
+{% endif %} + +

Charts

+
+
Throughput
+
Latency
+
Mempool depth
+
Gas throughput
+
Block timing
+ {% if charts.memory %} +
EL memory
+ {% endif %} +
Signals heatmap
+
+ +{% if charts.latency_dists %} +

Latency distributions by phase

+
+ {% for rate, b64 in charts.latency_dists %} +
Latency distribution — {{ rate }} TPS
+ {% endfor %} +
+{% endif %} + +{% if prom_charts %} +

Prometheus Metrics

+
+ {% for title, b64 in prom_charts.items() %} +
{{ title }}
+ {% endfor %} +
+{% endif %} + +

Raw Data

+
experiment.json +
{{ raw_json }}
+
+ +
+ + + + +""" + + +def _build_html(exp: dict, charts: dict, prom_charts: dict[str, str]) -> str: + phases = exp.get("phases", []) + params = exp.get("parameters", {}) + # New schema (saturation runner ≥ 2026-06-25): manifest TOML embedded as + # a JSON object under `parameters.manifest`. Old schema: a hand-picked + # `Topology` struct at `parameters.topology`. Fall back to the old field + # when the new one is absent so historical experiment.json files still render. + manifest = params.get("manifest") or {} + topology = params.get("topology") or (_topology_from_manifest(manifest) if manifest else {}) + status = exp.get("status", {}) + status_t = status.get("type", "unknown") + sat_phase = find_saturation_phase(phases) + + started = _parse_rfc3339(exp.get("started_at") or "") + ended = _parse_rfc3339(exp.get("ended_at") or "") + + prepared_phases = [] + for phase in phases: + m = phase.get("metrics", {}) + evictions = m.get("pool_evictions") + # Reth caps each sub-pool on both count and size; render the cell as + # ``count(MB)`` so a reader sees both dimensions at a glance and can + # spot which one is binding when "txpool is full" fires. + as_f = lambda x: float(x) if x is not None else None # noqa: E731 + prepared_phases.append({ + "offered_tps": phase["offered_tps"], + "actual_offered_tps": _fmt(m.get("actual_offered_tps"), ".0f"), + "actual_offered_bytes_per_sec": _fmt_gas(m.get("actual_offered_bytes_per_sec")), + "actual_tps": _fmt(m.get("actual_tps"), ".0f"), + "gas_per_sec": _fmt_gas(m.get("gas_per_sec")), + "fill_pct": _fmt(m.get("fill_pct"), ".1f", "%"), + "avg_block_time_s": _fmt(m.get("avg_block_time_s"), ".2f", "s"), + "avg_block_build_time_ms": _fmt(m.get("avg_block_build_time_ms"), ".0f"), + "avg_block_finalize_time_ms": _fmt(m.get("avg_block_finalize_time_ms"), ".0f"), + "el_mem_avg_mb": _fmt(m.get("el_mem_avg_mb"), ".0f"), + "el_mem_peak_mb": _fmt(m.get("el_mem_peak_mb"), ".0f"), + "latency_avg_sd_ms": _fmt_avg_sd(m.get("latency_avg_ms"), m.get("latency_stddev_ms")), + "latency_p50_ms": _fmt(m.get("latency_p50_ms"), ".0f"), + "latency_p95_ms": _fmt(m.get("latency_p95_ms"), ".0f"), + "peak_pending": _fmt_count_mb(as_f(m.get("max_mempool")), m.get("max_pending_size_mb")), + "avg_pending": _fmt_count_mb(m.get("avg_pending_mempool"), m.get("avg_pending_size_mb")), + "peak_queued": _fmt_count_mb(as_f(m.get("max_queued_mempool")), m.get("max_queued_size_mb")), + "avg_queued": _fmt_count_mb(m.get("avg_queued_mempool"), m.get("avg_queued_size_mb")), + "peak_basefee": _fmt_count_mb(m.get("max_basefee_mempool"), m.get("max_basefee_size_mb")), + "avg_basefee": _fmt_count_mb(m.get("avg_basefee_mempool"), m.get("avg_basefee_size_mb")), + "pool_evictions": _fmt(evictions, ".0f") if evictions is not None else "—", + "el_cpu_avg_pct": _fmt(m.get("el_cpu_avg_pct"), ".0f", "%"), + "el_cpu_max_pct": _fmt(m.get("el_cpu_max_pct"), ".0f", "%"), + "cl_cpu_avg_pct": _fmt(m.get("cl_cpu_avg_pct"), ".0f", "%"), + "cl_cpu_max_pct": _fmt(m.get("cl_cpu_max_pct"), ".0f", "%"), + "signal_labels": [SIGNAL_LABELS.get(s, s) for s in phase.get("signals", [])], + "is_sat": sat_phase is not None and phase["offered_tps"] == sat_phase["offered_tps"], + "rpc_errors": sorted(m.get("rpc_errors", {}).items(), key=lambda kv: -kv[1]), + }) + + ctx = { + "experiment_id": exp.get("experiment_id", ""), + "status_type": status_t, + "status_type_display": status_t.replace("_", " "), + "status_reason": status.get("reason", "") if status_t == "failed" else "", + "started_str": started.strftime("%Y-%m-%d %H:%M UTC") if started else "", + "ended_str": ended.strftime("%H:%M UTC") if ended else "", + "duration": _fmt_duration((ended - started).total_seconds()) if started and ended else "", + "params_rates": ", ".join(str(r) for r in params.get("rates", [])), + "params_hold": _fmt_duration(params.get("hold_secs", 0)), + "params_warmup": _fmt_duration(params.get("rampup_secs", 0)), + "params_cooldown": _fmt_duration(params.get("cooldown_secs", 0)), + "params_generators": str(params.get("generators", "")), + "params_tx_mix": params.get("tx_mix", ""), + "params_guzzler_fn_weights": params.get("guzzler_fn_weights", ""), + "params_erc20_fn_weights": params.get("erc20_fn_weights", ""), + "topo_num_nodes": topology.get("num_nodes", ""), + "topo_num_validators": topology.get("num_validators", ""), + "topo_node_types": _fmt_node_types(topology.get("nodes_by_type") or {}), + "topo_node_size": topology.get("node_size"), + "topo_node_disk": _fmt_node_disk(topology), + "topo_el_resources": _fmt_container_resources( + topology.get("el_cpu_limit"), + topology.get("el_memory_limit_gb"), + ), + "topo_cl_resources": _fmt_container_resources( + topology.get("cl_cpu_limit"), + topology.get("cl_memory_limit_gb"), + ), + "topo_account_balance": _fmt_usdc(topology.get("extra_account_balance_usdc")), + "topo_block_gas_limit": f"{topology.get('block_gas_limit'):,}" if topology.get("block_gas_limit") else "—", + "topo_cc_size": topology.get("cc_size"), + "topo_cc_disk_gb": topology.get("cc_disk_gb"), + "topo_image_el": topology.get("image_el"), + "topo_image_cl": topology.get("image_cl"), + "topo_el_storage": "V2" if topology.get("el_storage_v2", True) else "V1", + "manifest_json": json.dumps(manifest, indent=2) if manifest else None, + "sat_tps": sat_phase["offered_tps"] if sat_phase else None, + "sat_signal_labels": [SIGNAL_LABELS.get(s, s) for s in (sat_phase.get("signals", []) if sat_phase else [])], + "phases": prepared_phases, + "charts": charts, + "prom_charts": prom_charts, + "raw_json": json.dumps(exp, indent=2), + } + + env = Environment(autoescape=True) + return env.from_string(_TEMPLATE).render(**ctx) + +# ── Entry point ─────────────────────────────────────────────────────────────── + +def parse_args(argv: list[str]) -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Generate a self-contained HTML report from a quake saturation experiment." + ) + p.add_argument("experiment_dir", type=Path, help="Path to the experiment output directory.") + return p.parse_args(argv) + + +def main(argv: list[str] = sys.argv[1:]) -> None: + args = parse_args(argv) + experiment_dir = args.experiment_dir.resolve() + + exp = load_experiment(experiment_dir) + phases = exp.get("phases", []) + + if not phases: + print("warning: no phases in experiment.json — report will be sparse", file=sys.stderr) + + sat_phase = find_saturation_phase(phases) + sat_idx = saturation_index(phases, sat_phase) + exp_start = _parse_rfc3339(exp.get("started_at") or "") + + plt.rcParams.update({"figure.facecolor": "white", "axes.facecolor": "white"}) + + charts: dict[str, str] = { + "throughput": chart_throughput(phases, sat_idx), + "gas": chart_gas(phases, sat_idx), + "latency": chart_latency(phases, sat_idx), + "mempool": chart_mempool(phases, sat_idx), + "signals": chart_signals(phases), + "block_timing": chart_block_timing(phases, sat_idx), + "memory": chart_memory(phases, sat_idx), + } + + charts["latency_dists"] = chart_latency_dist(experiment_dir, phases) + + # Phase start annotations for Prometheus time-series charts (offset from exp_start) + phase_starts: list[tuple[float, int]] = [] + if exp_start is not None: + t0 = exp_start.timestamp() + for p in phases: + ps = _parse_rfc3339(p.get("started_at") or "") + if ps is not None: + phase_starts.append(((ps.timestamp() - t0) / 60, p["offered_tps"])) + + prom_metrics = load_metrics_tarball(experiment_dir) + prom_charts: dict[str, str] = {} + + # Stable per-node color assignment used across every Prometheus chart so + # the standalone legend below matches every line in every graph. + per_node_series = [ + prom_metrics.get(m) or [] + for m in [ + "reth_process_resident_memory_bytes", + *[cfg[0] for cfg in _PROM_CHART_CONFIG], + ] + ] + node_colors = _build_node_color_map(per_node_series) + + legend_chart = chart_prom_legend(node_colors) + if legend_chart: + prom_charts["Node color legend"] = legend_chart + + eviction_chart = chart_pool_evictions_prom(prom_metrics, exp_start, phase_starts) + if eviction_chart: + prom_charts["Pool Evictions by Sub-pool"] = eviction_chart + + memory_prom_chart = chart_memory_prom(prom_metrics, exp_start, phase_starts, node_colors) + if memory_prom_chart: + prom_charts["EL Resident Memory (MiB)"] = memory_prom_chart + + for metric_name, title, ylabel, mode in _PROM_CHART_CONFIG: + series = prom_metrics.get(metric_name) + if not series: + continue + count_series = None + if mode == "avg": + # Pair _sum metric with its corresponding _count series + count_name = metric_name.replace("_sum", "_count") + count_series = prom_metrics.get(count_name) + if not count_series: + # Fall back to block time count as a proxy denominator + count_series = prom_metrics.get("arc_malachite_app_block_time_count") + prom_charts[title] = chart_prometheus_metric( + series, title, ylabel, mode, exp_start, phase_starts, count_series, + node_colors=node_colors, + ) + + report = _build_html(exp, charts, prom_charts) + + out_path = experiment_dir / "report.html" + out_path.write_text(report, encoding="utf-8") + print(f"Report written to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/scenarios/nightly-chaos-testing.sh b/scripts/scenarios/nightly-chaos-testing.sh index 15356f7f..e6cf1566 100755 --- a/scripts/scenarios/nightly-chaos-testing.sh +++ b/scripts/scenarios/nightly-chaos-testing.sh @@ -71,9 +71,16 @@ QUAKE="./target/debug/quake" echo "[1/6] Building (genesis, Docker images, quake)..." make genesis -make build-docker - -cargo build --bin quake +# When QUAKE_SKIP_BUILD is set, the caller has already provided the +# arc_execution:latest / arc_consensus:latest images and the quake binary at +# target/debug/quake (e.g. restored from a build cache), so skip the expensive +# node image and quake builds. +if [[ -n "${QUAKE_SKIP_BUILD:-}" ]]; then + echo "QUAKE_SKIP_BUILD set: reusing prebuilt node images and quake binary, skipping build" +else + make build-docker + cargo build --bin quake +fi echo "[2/6] Running quake cleanup..." "$QUAKE" --seed "$SEED" -f "$SCENARIO" clean --all 2>/dev/null diff --git a/scripts/scenarios/nightly-random-manifests.sh b/scripts/scenarios/nightly-random-manifests.sh index 86e71b90..a7579cf1 100644 --- a/scripts/scenarios/nightly-random-manifests.sh +++ b/scripts/scenarios/nightly-random-manifests.sh @@ -75,8 +75,17 @@ run_phase() { } echo "[1/3] Building (genesis, Docker images, quake)..." -make genesis build-docker -cargo build --bin quake +make genesis +# When QUAKE_SKIP_BUILD is set, the caller has already provided the +# arc_execution:latest / arc_consensus:latest images and the quake binary at +# target/debug/quake (e.g. restored from a build cache), so skip the expensive +# node image and quake builds. +if [[ -n "${QUAKE_SKIP_BUILD:-}" ]]; then + echo "QUAKE_SKIP_BUILD set: reusing prebuilt node images and quake binary, skipping build" +else + make build-docker + cargo build --bin quake +fi echo "[2/3] Generating random manifests..." if [[ -n "${QUAKE_GENERATE_SEED:-}" ]]; then diff --git a/scripts/scenarios/nightly-upgrade.sh b/scripts/scenarios/nightly-upgrade.sh index 9f85edde..6c257be7 100755 --- a/scripts/scenarios/nightly-upgrade.sh +++ b/scripts/scenarios/nightly-upgrade.sh @@ -31,7 +31,7 @@ echo "Repository root: $REPO_ROOT" SCENARIO="${1:-crates/quake/scenarios/nightly-upgrade.toml}" LOAD_DURATION="${2:-90}" LOAD_RATE="${3:-100}" -NEXT_HARDFORK_NAME="${4:-zero6}" +NEXT_HARDFORK_NAME="${4:-zero8}" UPGRADE_TO_HARDFORK_BLOCKS=60 # block-based hardfork: activate this many blocks ahead UPGRADE_TO_HARDFORK_SECONDS=60 # timestamp-based hardfork: activate this many seconds ahead UPGRADE_TO_OSAKA_SECONDS=300 # osakaTime is always timestamp-patched; activate this many seconds ahead diff --git a/scripts/scenarios/storage-v2-migration.sh b/scripts/scenarios/storage-v2-migration.sh new file mode 100755 index 00000000..325811dd --- /dev/null +++ b/scripts/scenarios/storage-v2-migration.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# V1 -> V2 storage migration smoke test. Runs against an already-started quake testnet: +# generate load, migrate one node to V2, verify root/balances, then verify the mixed +# V1/V2 network reaches consensus. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + +QUAKE="${QUAKE:-cargo run --quiet --bin quake --}" +MANIFEST="${QUAKE_MANIFEST:-crates/quake/scenarios/storage-v2-migration.toml}" +TARGET="${TARGET_NODE:-validator3}" +RATE="${RATE:-100}" +TIME="${TIME:-30}" +NODES=(validator1 validator2 validator3 validator4 validator5) + +TESTNET_NAME="$(basename "$MANIFEST" .toml)" +COMPOSE=".quake/${TESTNET_NAME}/compose.yaml" +RESULTS_DIR="${RESULTS_DIR:-target/smoke-results/${TESTNET_NAME}}" +mkdir -p "$RESULTS_DIR" + +# Fee-recipient sample addresses; MUST match the manifest. +SAMPLE_ADDRS=( + 0x1111111111111111111111111111111111111111 + 0x2222222222222222222222222222222222222222 + 0x3333333333333333333333333333333333333333 +) + +log() { printf '\n=== %s ===\n' "$*" >&2; } +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +dec() { printf '%d' "$1"; } # bash printf parses 0x.. as hex + +# Run a command with a hard time limit; portable (macOS lacks coreutils `timeout`). +bounded() { + local secs="$1"; shift + "$@" & + local pid=$! + ( sleep "$secs"; kill -KILL "$pid" 2>/dev/null ) & + local watcher=$! + wait "$pid" 2>/dev/null + local rc=$? + kill -KILL "$watcher" 2>/dev/null + wait "$watcher" 2>/dev/null + return "$rc" +} + +dump_diagnostics() { + # shellcheck disable=SC2086 + bounded 30 $QUAKE -f "$MANIFEST" info heights >"$RESULTS_DIR/heights.txt" 2>&1 || true + bounded 30 docker compose -f "$COMPOSE" ps >"$RESULTS_DIR/ps.txt" 2>&1 || true +} +on_exit() { + local rc=$? + [ "$rc" -ne 0 ] && dump_diagnostics + exit "$rc" +} +trap on_exit EXIT + +# shellcheck disable=SC2086 +quake_cmd() { $QUAKE -f "$MANIFEST" "$@"; } + +# Published host RPC for a node's EL container. +el_rpc() { + local node="$1" hostport + hostport="$(docker compose -f "$COMPOSE" port "${node}_el" 8545 | cut -d: -f2)" + [ -n "$hostport" ] || fail "could not resolve RPC port for ${node}_el" + printf 'http://127.0.0.1:%s' "$hostport" +} + +# rpc -> raw JSON +rpc() { + curl -s "$1" -H 'content-type: application/json' \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$2\",\"params\":$3}" +} +block_number() { dec "$(rpc "$1" eth_blockNumber '[]' | jq -r '.result')"; } + +# Poll until a node reaches >= want, or fail after timeout seconds. +wait_height() { + local url="$1" want="$2" deadline=$(( SECONDS + ${3:-180} )) cur=-1 + while (( SECONDS < deadline )); do + cur="$(block_number "$url" 2>/dev/null || echo -1)" + (( cur >= want )) && return 0 + sleep 2 + done + fail "node did not reach height $want within ${3:-180}s (last=$cur)" +} + +# Run an arc-node-execution `db` subcommand against the target's (stopped) datadir volume. +node_db() { + docker compose -f "$COMPOSE" run --rm --no-deps -T \ + --entrypoint /usr/local/bin/arc-node-execution "${TARGET}_el" \ + db --datadir=/data/reth/execution-data --chain=/app/assets/genesis.json "$@" +} + +# Persisted storage layout for the target datadir: "true" (V2) or "false" (V1). +storage_is_v2() { + node_db settings get 2>/dev/null | sed -n -E 's/.*storage_v2: (true|false).*/\1/p' +} + +RPC="$(el_rpc "$TARGET")" + +log "Phase 1: wait for network to produce blocks" +wait_height "$RPC" 3 120 +grep -q -- "--storage.v2=false" "$COMPOSE" || fail "rendered compose lacks --storage.v2=false; nodes would start in V2" + +log "Phase 2: generate load (${RATE} tps for ${TIME}s)" +pre_load="$(block_number "$RPC")" +quake_cmd load -r "$RATE" -t "$TIME" 2>&1 | tee "$RESULTS_DIR/load.log" + +log "Phase 3: confirm load produced state" +wait_height "$RPC" $(( pre_load + 10 )) 120 +log "head advanced from ${pre_load} to $(block_number "$RPC")" + +log "Phase 4: stop target CL so its EL freezes at N (the other validators keep producing)" +quake_cmd stop "${TARGET}_cl" +sleep 4 # let the last in-flight block settle; the target's head must be stable for the baseline + +log "Phase 5: capture baseline" +head_json="$(rpc "$RPC" eth_getBlockByNumber '["latest",false]')" +N_HEX="$(jq -r '.result.number' <<<"$head_json")" +S="$(jq -r '.result.stateRoot' <<<"$head_json")" +N="$(dec "$N_HEX")" +OLD_HEX="$(printf '0x%x' $(( N / 2 )))" # an older block to exercise history indices +[ "$N" -gt 1 ] || fail "head N=$N too low to sample an older block" +{ + echo "N=$N"; echo "N_HEX=$N_HEX"; echo "S=$S"; echo "OLD_HEX=$OLD_HEX" + for a in "${SAMPLE_ADDRS[@]}"; do + echo "bal_head_${a}=$(rpc "$RPC" eth_getBalance "[\"$a\",\"$N_HEX\"]" | jq -r .result)" + echo "bal_old_${a}=$(rpc "$RPC" eth_getBalance "[\"$a\",\"$OLD_HEX\"]" | jq -r .result)" + done +} | tee "$RESULTS_DIR/baseline.txt" + +log "Phase 6: stop target EL; confirm it is V1 before migrating" +quake_cmd stop "${TARGET}_el" +[ "$(storage_is_v2)" = false ] || fail "target datadir is not V1 before migration" + +log "Phase 7: migrate V1 -> V2" +# Exit code (via pipefail) catches failure; the settings check catches a silent no-op. +node_db migrate-v2 2>&1 | tee "$RESULTS_DIR/migrate-v2.log" +[ "$(storage_is_v2)" = true ] || fail "storage_v2 not set after migration" + +log "Phase 8: re-run migrate-v2 is an idempotent no-op (exit 0, still V2)" +node_db migrate-v2 2>&1 | tee "$RESULTS_DIR/migrate-v2-noop.log" +[ "$(storage_is_v2)" = true ] || fail "storage_v2 flipped off after no-op re-run" + +log "Phase 9: restart target EL alone; wait for rebuild to N" +quake_cmd perturb restart "${TARGET}_el" +quake_cmd wait height "$N" "$TARGET" --timeout 600 # EL rebuilds from local data; no CL needed + +log "Phase 10: verify rebuilt state matches baseline" +S2="$(rpc "$RPC" eth_getBlockByNumber "[\"$N_HEX\",false]" | jq -r '.result.stateRoot')" +[ "$S2" = "$S" ] || fail "stateRoot mismatch at N=$N: pre=$S post=$S2" +for a in "${SAMPLE_ADDRS[@]}"; do + exp_head="$(grep "^bal_head_${a}=" "$RESULTS_DIR/baseline.txt" | cut -d= -f2)" + got_head="$(rpc "$RPC" eth_getBalance "[\"$a\",\"$N_HEX\"]" | jq -r .result)" + [ "$got_head" = "$exp_head" ] || fail "balance@head mismatch $a: $exp_head != $got_head" + exp_old="$(grep "^bal_old_${a}=" "$RESULTS_DIR/baseline.txt" | cut -d= -f2)" + got_old="$(rpc "$RPC" eth_getBalance "[\"$a\",\"$OLD_HEX\"]" | jq -r .result)" + [ "$got_old" = "$exp_old" ] || fail "balance@old mismatch $a: $exp_old != $got_old" +done +log "migration correctness verified: stateRoot and balances match at N=$N" + +log "Phase 11: restart target CL; network resumes with mixed V1/V2 stores" +quake_cmd perturb restart "${TARGET}_cl" + +log "Phase 12: verify all nodes advance past N and agree on stateRoot" +target_M=$(( N + 5 )) +for node in "${NODES[@]}"; do wait_height "$(el_rpc "$node")" "$target_M" 240; done +M_HEX="$(printf '0x%x' "$target_M")" +root_ref="" +for node in "${NODES[@]}"; do + r="$(rpc "$(el_rpc "$node")" eth_getBlockByNumber "[\"$M_HEX\",false]" | jq -r '.result.stateRoot')" + [ -n "$r" ] && [ "$r" != null ] || fail "$node missing block $target_M" + [ -n "$root_ref" ] || root_ref="$r" + [ "$r" = "$root_ref" ] || fail "stateRoot disagreement at M=$target_M: $node=$r vs $root_ref" +done +log "SUCCESS: mixed V1/V2 network agrees on stateRoot at M=$target_M" diff --git a/scripts/update-malachite-deps.sh b/scripts/update-malachite-deps.sh index 9667da40..94bf5946 100755 --- a/scripts/update-malachite-deps.sh +++ b/scripts/update-malachite-deps.sh @@ -258,6 +258,7 @@ DEPS=( "malachitebft-core-state-machine" "malachitebft-core-types" # "malachitebft-engine" + "malachitebft-engine-byzantine" # "malachitebft-metrics" "malachitebft-network" "malachitebft-peer" diff --git a/tests/helpers/consensus.ts b/tests/helpers/consensus.ts new file mode 100644 index 00000000..1b07b70e --- /dev/null +++ b/tests/helpers/consensus.ts @@ -0,0 +1,112 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Client for the consensus-layer (Malachite) RPC server. The localdev testnet +// publishes each validator's RPC on the host at `31000 + validatorIndex`. + +const CONSENSUS_RPC_BASE_PORT = 31000 + +export interface ConsensusValidator { + address: string + voting_power: number + public_key_hex: string +} + +export interface ConsensusValidatorSet { + total_voting_power: number + count: number + validators: ConsensusValidator[] +} + +export interface ConsensusStatus { + height: number + round: number + validator_set: ConsensusValidatorSet +} + +/** + * RPC URL of a localdev validator's consensus layer. Index `i` maps to + * `validator${i + 1}`. `LOCALDEV_CL_RPC_URL` overrides the first validator's URL. + */ +export const consensusRpcUrl = (validatorIndex = 0): string => { + const override = process.env.LOCALDEV_CL_RPC_URL + if (override && validatorIndex === 0) { + return override + } + return `http://localhost:${CONSENSUS_RPC_BASE_PORT + validatorIndex}` +} + +/** + * Fetch the consensus-layer status, including the current signing validator set. + */ +export const getConsensusStatus = async (url: string = consensusRpcUrl()): Promise => { + const response = await fetch(`${url}/status`, { + headers: { Accept: 'application/vnd.arc.v1+json' }, + }) + if (!response.ok) { + throw new Error(`Consensus RPC ${url}/status returned ${response.status}`) + } + const body: unknown = await response.json() + return body as ConsensusStatus +} + +/** + * Find a validator in a consensus status by its public key (`0x`-prefixed, + * case-insensitive). Returns undefined when absent. + */ +export const findConsensusValidator = (status: ConsensusStatus, publicKeyHex: string): ConsensusValidator | undefined => + status.validator_set.validators.find((v) => v.public_key_hex.toLowerCase() === publicKeyHex.toLowerCase()) + +export interface WaitForConsensusStatusOptions { + url?: string + timeoutMs?: number + intervalMs?: number +} + +/** + * Poll the consensus status until `predicate` holds, returning the matching + * status. Transient fetch failures (e.g. a 503 while the validator set is + * reconfiguring) are retried; only a timeout throws. + */ +export const waitForConsensusStatus = async ( + predicate: (status: ConsensusStatus) => boolean, + { url = consensusRpcUrl(), timeoutMs = 60_000, intervalMs = 1_000 }: WaitForConsensusStatusOptions = {}, +): Promise => { + const deadline = Date.now() + timeoutMs + let lastError: unknown + let lastStatus: ConsensusStatus | undefined + for (;;) { + try { + lastStatus = await getConsensusStatus(url) + lastError = undefined + if (predicate(lastStatus)) { + return lastStatus + } + } catch (error) { + lastError = error + } + if (Date.now() >= deadline) { + const detail = + lastError !== undefined + ? `last error: ${lastError instanceof Error ? lastError.message : 'unknown error'}` + : `last validator set count=${lastStatus?.validator_set.count ?? 'unknown'}` + throw new Error(`Timed out after ${timeoutMs}ms waiting for consensus status at ${url}; ${detail}`) + } + await new Promise((resolve) => { + setTimeout(resolve, intervalMs) + }) + } +} diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts index 36e49cee..2ccf3413 100644 --- a/tests/helpers/index.ts +++ b/tests/helpers/index.ts @@ -23,6 +23,7 @@ export * from './ProtocolConfig' export * from './ReceiptVerifier' export * from './TraceVerifier' export * from './networks' +export * from './consensus' export * from './matchers' export * from './client-extension' export * from './RevertingProtocolConfig' diff --git a/tests/localdev/BundleRpc.test.ts b/tests/localdev/BundleRpc.test.ts new file mode 100644 index 00000000..0611d0d2 --- /dev/null +++ b/tests/localdev/BundleRpc.test.ts @@ -0,0 +1,80 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai' +import { MethodNotFoundRpcError, toHex } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { getClients } from '../helpers/networks' + +const BUNDLE_RPC_METHODS = [ + 'eth_callBundle', + 'eth_sendBundle', + 'eth_cancelBundle', + 'eth_sendPrivateTransaction', + 'eth_sendPrivateRawTransaction', + 'eth_cancelPrivateTransaction', +] as const + +type CallBundleParams = [ + { + txs: [`0x${string}`] + blockNumber: `0x${string}` + stateBlockNumber: 'latest' + baseFee: '0x0' + }, +] + +describe('Bundle RPC methods', () => { + for (const method of BUNDLE_RPC_METHODS) { + // Scenario: A bundle or private-transaction method is called through the public HTTP RPC. + // Call flow: test → HTTP JSON-RPC → public RPC method lookup + // Assertions: the method is absent and returns the standard method-not-found error. + it(`does not expose ${method}`, async () => { + const { client } = await getClients() + await expect(client.request<{ Parameters: []; ReturnType: unknown }>({ method, params: [] })).to.be.rejectedWith( + MethodNotFoundRpcError, + ) + }) + } + + // Scenario: A valid executable bundle is submitted through the public HTTP RPC. + // Call flow: fresh signer → signed zero-fee transfer → eth_callBundle simulation + // Assertions: eth_callBundle is absent even when the request would execute successfully in unmodified Reth. + it('does not execute eth_callBundle with valid params', async () => { + const { chain, client } = await getClients() + const account = privateKeyToAccount(generatePrivateKey()) + const rawTransaction = await account.signTransaction({ + chainId: chain.id, + gas: 21_000n, + gasPrice: 0n, + nonce: 0, + to: account.address, + value: 0n, + }) + const params: CallBundleParams = [ + { + txs: [rawTransaction], + blockNumber: toHex((await client.getBlockNumber()) + 1n), + stateBlockNumber: 'latest', + baseFee: '0x0', + }, + ] + + await expect( + client.request<{ Parameters: CallBundleParams; ReturnType: unknown }>({ method: 'eth_callBundle', params }), + ).to.be.rejectedWith(MethodNotFoundRpcError) + }) +}) diff --git a/tests/localdev/NativeFiatToken.test.ts b/tests/localdev/NativeFiatToken.test.ts index ae292387..5c61ae4d 100644 --- a/tests/localdev/NativeFiatToken.test.ts +++ b/tests/localdev/NativeFiatToken.test.ts @@ -457,8 +457,6 @@ describe('NativeFiatToken', () => { .then(ReceiptVerifier.waitSuccess) // Verify blocklist event was emitted - // Zero5: EIP-2929 warm/cold gas pricing - // +3526 gas for owner blocklist protection check blocklistReceipt.verifyGasUsedApproximately(61574n).verifyEvents((ev) => { ev.expectNativeBlocklisted({ account: targetAccount }) .expectUSDCBlacklisted({ account: targetAccount }) @@ -504,8 +502,6 @@ describe('NativeFiatToken', () => { .then(ReceiptVerifier.waitSuccess) // Verify blocklist event was emitted - // Zero5: EIP-2929 warm/cold gas pricing - // +3526 gas for owner blocklist protection check blocklistReceipt.verifyGasUsedApproximately(61574n).verifyEvents((ev) => { ev.expectNativeBlocklisted({ account: sender }) .expectUSDCBlacklisted({ account: sender }) @@ -533,6 +529,56 @@ describe('NativeFiatToken', () => { .then((hash: `0x${string}`) => client.waitForTransactionReceipt({ hash })) }) + it('mempool blocklist: stuck tx evicted from pool when recipient is blocklisted', async () => { + const { client, operator, createRandWallet } = await clients() + const amount = parseEther('0.0000001') + + const blocklistRecipient = await createRandWallet() + const nonce = await client.getTransactionCount({ address: operator.account.address }) + + // stuckTx: gap nonce (N+1) — enters pool but stalls waiting for nonce N + const stuckTxHash = await operator.sendTransaction({ + to: blocklistRecipient.account.address, + value: amount, + nonce: nonce + 1, + }) + + // Confirm stuckTx is pending in the mempool (blockHash null = not yet mined) + const stuckTxPending = await client.getTransaction({ hash: stuckTxHash }) + expect(stuckTxPending.blockHash).to.be.null + + // blocklistTx: blocklist blocklistRecipient with correct nonce N — mines and fills the gap + // eth_getTransactionCount(operator,'pending') returns N because the gap at N prevents advancement + const blocklistReceipt = await USDC.attach(operator) + .write.blacklist([blocklistRecipient.account.address]) + .then(ReceiptVerifier.waitSuccess) + // operator on-chain nonce = N+1; blocklistRecipient blocked; stuckTx now at queue front + + blocklistReceipt.verifyGasUsedApproximately(61574n).verifyEvents((ev) => { + ev.expectNativeBlocklisted({ account: blocklistRecipient }) + .expectUSDCBlacklisted({ account: blocklistRecipient }) + .expectAllEventsMatched() + }) + + const balances = await balancesSnapshot(client, { blocklistRecipient: blocklistRecipient.account.address }) + + // stuckTx evicted: poll until next block build completes the eviction + let stuckTxInfo + for (let i = 0; i < 10; i++) { + stuckTxInfo = await client.request({ method: 'eth_getTransactionByHash', params: [stuckTxHash] }) + if (stuckTxInfo === null) break + await new Promise((r) => setTimeout(r, 500)) + } + expect(stuckTxInfo).to.be.null + + // No transfer to blocklistRecipient occurred + await balances.verify() + + await USDC.attach(operator) + .write.unBlacklist([blocklistRecipient.account.address]) + .then(ReceiptVerifier.waitSuccess) + }) + it('pre-execution blocklist: blocklisted sender cannot transfer native coins', async () => { const { client, operator, createRandWallet, receiver } = await clients() const amount = parseEther('0.0000001') @@ -544,8 +590,6 @@ describe('NativeFiatToken', () => { .then(ReceiptVerifier.waitSuccess) // Verify blocklist event was emitted - // Zero5: EIP-2929 warm/cold gas pricing - // +3526 gas for owner blocklist protection check blocklistReceipt.verifyGasUsedApproximately(61562n).verifyEvents((ev) => { ev.expectNativeBlocklisted({ account: sender }) .expectUSDCBlacklisted({ account: sender }) @@ -1451,7 +1495,10 @@ describe('NativeFiatToken', () => { }) }) - it('draining an empty account will revert', async () => { + // Zero8 is active from genesis on localdev, so draining a nonce=0 & codeless account + // to empty is permitted and EIP-161 clears it at commit. Pre-Zero8 this reverts with + // ERR_CLEAR_EMPTY + it('draining an empty account succeeds and commits the cleared state', async () => { const { client, sender, operator, createRandWallet } = await clients() const usdcAmount = USDC.parseUnits('10') @@ -1468,9 +1515,9 @@ describe('NativeFiatToken', () => { }) expect(balances.state().emptyWalletNonce).to.equal(0n) - // Empty wallet signs an off-chain permit for SENDER to spend all 10 USDC - // This is EIP-2612 permit - // Sender will use this approval to transfer from empty wallet to themself + // Empty wallet signs an off-chain permit for SENDER to spend all 10 USDC. + // Sender submits it and drains via transferFrom, so emptyWallet never sends a + // tx — its nonce stays 0, keeping it EIP-161-clearable once fully drained. const signature = await signPermit({ client, wallet: emptyWallet, @@ -1487,31 +1534,26 @@ describe('NativeFiatToken', () => { ev.expectCount(1).expectUSDCApproval({ owner: emptyWallet, spender: sender, value: usdcAmount }) }) - // Sender drains all 10 USDC from wallet 1 to wallet 2 using transferFrom - // This will revert, since it is an empty account, being fully drained - await expect( - USDC.attach(sender).write.transferFrom([emptyWallet.account.address, sender.account.address, usdcAmount]), - ).rejectedWith(ContractFunctionExecutionError, 'Cannot clear balance of empty account') + // Sender drains all 10 USDC from emptyWallet. Under Zero8 this succeeds and + // is committed rather than reverting with ERR_CLEAR_EMPTY. + const drainReceipt = await USDC.attach(sender) + .write.transferFrom([emptyWallet.account.address, sender.account.address, usdcAmount]) + .then(ReceiptVerifier.waitSuccess) - await balances.verify() + // Exactly the full balance left emptyWallet; its nonce is unchanged (still 0). + await balances.decrease({ emptyWallet: USDC.toNative(usdcAmount) }).verify(drainReceipt.transactionHash) - // Now, send 1 wei of dust to the account, and try again - await sender - .sendTransaction({ - to: emptyWallet.account.address, - value: 1n, - }) - .then(ReceiptVerifier.waitSuccess) + // The account reads fully empty at the RPC layer. + expect(await client.getBalance({ address: emptyWallet.account.address })).to.equal(0n) + expect(await client.getTransactionCount({ address: emptyWallet.account.address })).to.equal(0) + expect(await client.getCode({ address: emptyWallet.account.address })).to.be.undefined - // This should succeed, but leave 1 wei behind - await USDC.attach(sender) - .write.transferFrom([emptyWallet.account.address, sender.account.address, usdcAmount]) + // The chain keeps committing state on top of the cleared account: a later + // block builds on the post-drain state root without halting. + const followUp = await sender + .sendTransaction({ to: operator.account.address, value: 1n }) .then(ReceiptVerifier.waitSuccess) - - await balances - .decrease({ emptyWallet: USDC.toNative(usdcAmount) }) - .increase({ emptyWallet: 1n }) - .verify() + expect(followUp.receipt.blockNumber > drainReceipt.receipt.blockNumber).to.be.true }) it('transferFrom clearing an accounts balance', async () => { diff --git a/tests/localdev/ProtocolConfig.test.ts b/tests/localdev/ProtocolConfig.test.ts index aa9fcbb4..e6214922 100644 --- a/tests/localdev/ProtocolConfig.test.ts +++ b/tests/localdev/ProtocolConfig.test.ts @@ -273,9 +273,8 @@ describe('ProtocolConfig Smoke Tests', function () { const gasLimit = feeParams.blockGasLimit const baseFeeMax = 200000000000n - const baseFeeMin = 1000000000n + const baseFeeMin = 20000000000n - // Drive up the base fee to notice base fee shifts more easily await updateBaseFeeBounds(baseFeeMin, baseFeeMax) await mineBlock() // Advance again @@ -610,25 +609,25 @@ describe('ProtocolConfig Smoke Tests', function () { }) it('does not exceed the configured max across multiple blocks', async function () { - // Set block gas limit low, to trigger base fee increases from smaller transactions const gasLimit = 1_000_000n await updateBlockGasLimit(gasLimit) const head = await publicClient.getBlock() const currentBaseFee = head.baseFeePerGas || 0n - // Set a tight cap near current to observe saturation const maxBaseFee = currentBaseFee + 5n const minBaseFee = currentBaseFee await updateBaseFeeBounds(minBaseFee, maxBaseFee) - // Mine several blocks, trying to push usage + // `eth_gasPrice` can lag below currentBaseFee at gwei-scale, leaving txs stuck under-priced. + const gasPrice = currentBaseFee * 2n + 1_000_000_000n + const guzzler = GasGuzzler.attach(sender, gasGuzzlerAddress) for (let i = 0; i < 5; i++) { const receipt = await guzzler.write .guzzle([200n], { gas: (gasLimit * 9n) / 10n, - gasPrice: await publicClient.getGasPrice(), + gasPrice, value: 0n, }) .then(ReceiptVerifier.waitSuccess) diff --git a/tests/localdev/ValidatorManager.test.ts b/tests/localdev/ValidatorManager.test.ts index 71fcfdc3..45bd26bc 100644 --- a/tests/localdev/ValidatorManager.test.ts +++ b/tests/localdev/ValidatorManager.test.ts @@ -15,7 +15,14 @@ // limitations under the License. import { expect } from 'chai' -import { ReceiptVerifier, getClients } from '../helpers' +import { + ReceiptVerifier, + getClients, + consensusRpcUrl, + getConsensusStatus, + findConsensusValidator, + waitForConsensusStatus, +} from '../helpers' import { getValidators } from '../helpers/networks/localdev' import { PermissionedValidatorManager, @@ -354,4 +361,87 @@ describe('ValidatorManager', () => { .then(ReceiptVerifier.waitSuccess) }) }) + + // Only runs under `make smoke-malachite` (ARC_SMOKE_SCENARIO=malachite), which + // runs a real consensus layer; smoke-reth uses a mock CL with no RPC server. + ;(process.env.ARC_SMOKE_SCENARIO === 'malachite' ? describe : describe.skip)( + 'Consensus Participation (malachite)', + () => { + it('reflects an added validator in the consensus-layer validator set', async function () { + this.timeout(180_000) + const { client, admin, validatorRegistry, registerer, sender, receiver, getController } = await clients() + + const clRpcUrl = consensusRpcUrl() + const initialCount = (await getConsensusStatus(clRpcUrl)).validator_set.count + + // Register, configure, fund and activate a new validator. + const privateKey = ed.utils.randomPrivateKey() + const publicKey = toHex(await ed.getPublicKeyAsync(privateKey)) + const registrationId = await validatorRegistry.getNextRegistrationId() + await PermissionedValidatorManager.attach(registerer).write.registerValidator([publicKey]) + + const controllerAccount = getController(registrationId, false) + const fundingTx = await sender.sendTransaction({ + account: sender.account, + to: controllerAccount.account.address, + value: parseEther('1'), + chain: null, + }) + await client.waitForTransactionReceipt({ hash: fundingTx }) + + await PermissionedValidatorManager.attach(admin) + .write.configureController([controllerAccount.account.address, registrationId, 10_000n]) + .then(ReceiptVerifier.waitSuccess) + await PermissionedValidatorManager.attach(controllerAccount) + .write.activateValidator() + .then(ReceiptVerifier.waitSuccess) + + // Validators with zero voting power are excluded from the consensus signing set, + // so assign a small positive power that keeps the proposer share negligible. + const votingPower = 1n + try { + await PermissionedValidatorManager.attach(controllerAccount) + .write.updateValidatorVotingPower([votingPower]) + .then(ReceiptVerifier.waitSuccess) + + const registered = await validatorRegistry.getValidator([registrationId]) + expect(registered.status).to.equal(ValidatorStatus.Active) + expect(registered.votingPower).to.equal(votingPower) + + // Advance a block so the consensus layer re-reads the registry. + await finalizeTx(client, sender, receiver, 'block progression after adding validator') + + const consensus = await waitForConsensusStatus( + (status) => findConsensusValidator(status, publicKey) !== undefined, + { url: clRpcUrl }, + ) + const consensusValidator = findConsensusValidator(consensus, publicKey) + if (consensusValidator === undefined) { + expect.fail('Added validator did not appear in the consensus validator set') + } + expect(consensusValidator.voting_power).to.equal(Number(votingPower)) + expect(consensus.validator_set.count).to.equal(initialCount + 1) + + // The chain keeps producing blocks with the expanded validator set. + await finalizeTx(client, sender, receiver, 'block progression with added validator') + } finally { + // Always restore the validator set so the shared testnet stays clean for + // later tests, even if an assertion above fails. + await PermissionedValidatorManager.attach(controllerAccount) + .write.removeValidator() + .then(ReceiptVerifier.waitSuccess) + await PermissionedValidatorManager.attach(admin) + .write.removeController([controllerAccount.account.address]) + .then(ReceiptVerifier.waitSuccess) + } + + // Removal propagates back to the consensus validator set. + const afterRemoval = await waitForConsensusStatus( + (status) => findConsensusValidator(status, publicKey) === undefined, + { url: clRpcUrl }, + ) + expect(afterRemoval.validator_set.count).to.equal(initialCount) + }) + }, + ) }) diff --git a/tests/localdev/evm_compatibility.test.ts b/tests/localdev/evm_compatibility.test.ts index 4232448c..db7fa6c5 100644 --- a/tests/localdev/evm_compatibility.test.ts +++ b/tests/localdev/evm_compatibility.test.ts @@ -1211,24 +1211,26 @@ describe('EVM compatibility', () => { const balc = await client.getBalance({ address: sender.account.address }) await expect( client.estimateGas({ account: sender.account.address, to: receiver.account.address, value: balc + 3n }), - ).to.be.rejectedWith(EstimateGasExecutionError, 'insufficient funds for gas * price + value') + ).to.be.rejectedWith(EstimateGasExecutionError, /OutOfFunds|insufficient funds/) }) it('balance check if gas price is provided', async () => { - const { sender, client, receiver } = clients + const { sender, client, A: receiver } = clients const balc = await client.getBalance({ address: sender.account.address }) - const block = await client.getBlock() - - // geth return "insufficient funds for transfer" - // reth return "Missing or invalid parameters" + const block = await client.getBlock({ blockTag: 'latest' }) + // Target a contract to skip Reth's basic-transfer shortcut, which bypasses + // the caller_gas_allowance cap now that disable_fee_charge is set in 2.2. + const maxFeePerGas = (block.baseFeePerGas ?? 0n) + 1_000_000_000n + const value = balc - 21000n * maxFeePerGas + 1n await expect( client.estimateGas({ account: sender.account.address, - to: receiver.account.address, - value: balc - 21000n, - maxFeePerGas: block.baseFeePerGas || 1n, // latest block base fee + to: receiver, + value, + maxFeePerGas, + maxPriorityFeePerGas: 1_000_000_000n, }), - ).to.be.rejectedWith(EstimateGasExecutionError, /(Missing or invalid parameters|insufficient funds for transfer)/) + ).to.be.rejectedWith(EstimateGasExecutionError, /insufficient funds for transfer|gas required exceeds allowance/) }) }) @@ -1245,7 +1247,7 @@ describe('EVM compatibility', () => { to: receiver.account.address, value: largeValue, }), - ).to.be.rejectedWith(/insufficient funds/) + ).to.be.rejectedWith(/OutOfFunds|insufficient funds/) // Call with overridden balance - should succeed const result = await client.call({ diff --git a/tests/localdev/genesis.test.ts b/tests/localdev/genesis.test.ts index f1b93555..4495b3ed 100644 --- a/tests/localdev/genesis.test.ts +++ b/tests/localdev/genesis.test.ts @@ -320,8 +320,8 @@ describe('genesis', () => { expect(feeParams.alpha).to.be.eq(20n) expect(feeParams.kRate).to.be.eq(200n) expect(feeParams.inverseElasticityMultiplier).to.be.eq(5000n) - expect(feeParams.minBaseFee).to.be.eq(1n) - expect(feeParams.maxBaseFee).to.be.eq(parseGwei('1000')) + expect(feeParams.minBaseFee).to.be.eq(parseGwei('20')) + expect(feeParams.maxBaseFee).to.be.eq(parseGwei('20000')) expect(feeParams.blockGasLimit).to.be.eq(30_000_000n) }) @@ -336,7 +336,7 @@ describe('genesis', () => { expect(consensusParams.timeoutPrevoteDeltaMs).to.be.eq(500) expect(consensusParams.timeoutPrecommitMs).to.be.eq(1000) expect(consensusParams.timeoutPrecommitDeltaMs).to.be.eq(500) - expect(consensusParams.timeoutRebroadcastMs).to.be.eq(1000) + expect(consensusParams.timeoutRebroadcastMs).to.be.eq(5000) expect(consensusParams.targetBlockTimeMs).to.be.eq(500) }) }) @@ -602,6 +602,23 @@ describe('genesis', () => { }) }) + // Extend when adding a new Arc hardfork: add an assertion for the new field here. + // All Arc hardforks activate at genesis (block 0 / timestamp 0) on localdev. + describe('hardfork activation', () => { + it('all Arc hardforks active at genesis on localdev', async () => { + const { client } = await getClients() + type AdminNodeInfo = { protocols: { eth: { config: Record } } } + const nodeInfo = (await client.request({ method: 'admin_nodeInfo' })) as unknown as AdminNodeInfo + const config = nodeInfo.protocols.eth.config + + expect(config.zero3Block, 'Zero3').to.equal(0) + expect(config.zero4Block, 'Zero4').to.equal(0) + expect(config.zero5Block, 'Zero5').to.equal(0) + expect(config.zero6Block, 'Zero6').to.equal(0) + expect(config.zero7Time, 'Zero7').to.equal(0) + }) + }) + describe('deployer nonce for one-time-address contracts', () => { const typedManifest = manifest as unknown as Manifest const oneTimeAddressEntries = Object.entries(typedManifest).filter(([, entry]) => entry.type === 'one-time-address') diff --git a/tests/localdev/native_transfer.test.ts b/tests/localdev/native_transfer.test.ts index 8f6218af..5749e4b4 100644 --- a/tests/localdev/native_transfer.test.ts +++ b/tests/localdev/native_transfer.test.ts @@ -490,7 +490,7 @@ describe('native transfer', () => { '0x7f', '0x0000000000000000000000000000000000000000000000000000000000000000', // push32 0 '0x60', '0x00', // PUSH1 0 '0x52', // MSTORE(0, 0) - '0x60', '0x01', // PUSH1 0, len = 1 + '0x60', '0x01', // PUSH1 1, len = 1 '0x60', '0x00', // PUSH1 0, offset = 0 '0x60', '0x01', // PUSH1 1, value = 1 '0xf0', // CREATE(1, 0, 1)