From 25af0df36ceec85a4c49ec10516ec7f9c598b78d Mon Sep 17 00:00:00 2001 From: circle-github-action-bot Date: Tue, 5 May 2026 11:36:34 +0000 Subject: [PATCH] chore: sync to arc-node - a6dbdc62a74b06440211b10798470c7f920cdade chore: sync from main@df0acc22a58fd8ff5c72db68b07230d478e... by circle-github-action-bot <[internal]> - c9b5d4bbba9ebe7e885c32963bf58f128cd06fc4 chore: sync from main@2aadb60dbf8f1af438cf553511d1359d75f... by circle-github-action-bot <[internal]> - 2373e19e5edc480fdf836d4ff98907c112572d27 chore: sync from main@f907347fbf66224354f0b3ae6daa9ae66bf... by circle-github-action-bot <[internal]> - 0cc64d87a658fae6dce2f1114665821a9b873e03 chore: sync from main@beb164a6733cefdbe599df21b7061eac505... by circle-github-action-bot <[internal]> - eafcb19abaa68cf72ce2f50bddb74d5be4fb3e8e chore: sync from main@e2bf6a8196b2a2a9f41d59655b959542736... by circle-github-action-bot <[internal]> - cc2cb166298fea53cb9b0199a1f2fe12ac5dd886 chore: sync from main@43eea704ba8b4cc2d4ed6e004ee505c6270... by circle-github-action-bot <[internal]> - dc109faf17805c2dbea83b5a70af50fe9e895a9c chore: sync from main@ad53554705af864b573850ec099ee161453... by circle-github-action-bot <[internal]> - 5e08c511a87538701d20973e7e98a431f970f9d6 chore: sync from main@7afdf5da31261a1be383942df4787766e4d... by circle-github-action-bot <[internal]> - c595b2521bfd3c846003e226d7a94f8c71318c9f chore: sync from main@725e71d61e8b6066278cf577a1702b5cdf8... by circle-github-action-bot <[internal]> - 5417c416488d439894f8e58765d2dca453a7684e chore: sync from main@99d1d50940653b375ef4c08f9c445f7c895... by circle-github-action-bot <[internal]> - 066ed535c39bbed1a979377ff00e988523385352 chore: sync from main@445d8134130e49e681ab1c65e6d12298c84... by circle-github-action-bot <[internal]> - cdd9512a9aab1f65f44872704c9fb985a7b8ee94 chore: sync from main@83889a9cd76d3b9e14436081852d0a668d9... by circle-github-action-bot <[internal]> - d5b8a75390d62414490380df773e7e2562a69662 chore: sync from main@9d74cea6b4e155737e55b5fd06c655f6e33... by circle-github-action-bot <[internal]> - e3b5e9b35831498bf2912a31f4338cac8300350c chore: sync from main@a466bb6a4f30f675f8ac6588a9936b1c9b8... by circle-github-action-bot <[internal]> - d8f63d49ba72dab903715356d4a36142095396dc chore: sync from main@620ce6979975400515c0705e43e865fbfef... by circle-github-action-bot <[internal]> - a14fa6a642ad095f3a1c6c6ea49066da8465d6f4 chore: sync from main@f1a95bfea35aa321e775f5d1d89a2cd000b... by circle-github-action-bot <[internal]> - 823e0534886260d29b2c66b4e05ac74e53ef77b3 chore: sync from main@d98120dad5580b73081708ea1d89498ab63... by circle-github-action-bot <[internal]> - 7bcb2f98ae7210fdaa3e3273f1eb20d77f306ae5 chore: sync from main@5cb6adf7d4342f3a654b49ae957090c0598... by circle-github-action-bot <[internal]> - b5f8d27f972a4e55d0723e88a99dde4d08759c64 chore: sync from main@109ea51c5273ea19e49908e8c53d87f60dd... by circle-github-action-bot <[internal]> - bc36e45262711bb31ae8d52cce2d463fb8d95057 ci: fix label-external-prs permissions (#1862) by circle-github-action-bot <[internal]> - d0af7956985993c013f2684bcf0a4ef38b4518df chore: sync from main@c14b4a468237a9a7c8de34da327c529c7b9... by circle-github-action-bot <[internal]> - 99315aaba3f47991884bfba52fe340ffe6f0a7e9 chore: sync from main@b622135690a52021000876bfef1b735486a... by circle-github-action-bot <[internal]> - 1a7ab53158fd112da012929fe235c9900531ba8c chore: sync from main@cdba81ee0abc298551dcb860fc8552b0fee... by circle-github-action-bot <[internal]> - a74a2635e7cdddd3502156f1781701ac2ca80673 chore: sync from main@6daf89f94f4a9e68ba9ff60b2b095329f79... by circle-github-action-bot <[internal]> - 081231c0e6085c3e514093c91d428c24227183d2 chore: sync from main@b101c7605f202c03f8fe975de84be5eb853... by circle-github-action-bot <[internal]> - 67952ff3bc834635a0415e8125aa3854f1b91fb2 chore: sync from main@45d3b3376169580afcdc6a78d48b1eb85e2... by circle-github-action-bot <[internal]> - af59319afc280d2c77760d10044fae4bdc59db31 chore: sync from main@8b28cfcd742ef60e5eb28512848e99256eb... by circle-github-action-bot <[internal]> - 0b246012aed0b090d1cf48ff3c4eb849e0f5eadf chore: sync from main@3a4e173e6e567e51bb02a2eaffd7b054c7c... by circle-github-action-bot <[internal]> - be9177f339df714d770f9de2b8fea170920ad2bb chore: sync from main@964462dafe4cbbb44f2a6a96f4bab4ac585... by circle-github-action-bot <[internal]> - f64606d4ef9b27127fc4aecaaa453f6639e4241c chore: sync from main@4033dc7ace25c370b4aadf99f2caee418e6... by circle-github-action-bot <[internal]> - cbd3798d92b8aa029d28b43354b82266df7edfea chore: sync from main@783387ef87a384c6d986309a6ec1ba7961e... by circle-github-action-bot <[internal]> GitOrigin-RevId: a6dbdc62a74b06440211b10798470c7f920cdade --- .cargo/audit.toml | 9 + .github/workflows/label-external-prs.yml | 14 +- Cargo.lock | 149 +++- Cargo.toml | 2 + Makefile | 16 +- README.md | 4 +- assets/devnet/genesis.config.ts | 1 - assets/localdev/genesis.config.ts | 12 +- assets/localdev/genesis.json | 15 +- assets/testnet/genesis.config.ts | 1 - contracts/scripts/Addresses.sol | 4 +- .../scripts/ProtocolConfigManagement.s.sol | 35 +- contracts/src/batch/IMulticall3From.sol | 13 + contracts/src/batch/Multicall3From.sol | 3 + contracts/src/memo/IMemo.sol | 8 + contracts/src/memo/Memo.sol | 2 + .../src/protocol-config/ProtocolConfig.sol | 23 +- .../interfaces/IProtocolConfig.sol | 14 - .../test/protocol-config/ProtocolConfig.t.sol | 139 +--- .../protocol-config/ProtocolConfigProxy.t.sol | 31 +- .../scripts/ProtocolConfigManagement.t.sol | 21 - crates/consensus-db/src/lib.rs | 5 +- crates/consensus-db/src/store.rs | 134 ++++ crates/evm-node/src/node.rs | 7 +- crates/evm-node/src/rpc_middleware.rs | 34 +- crates/evm/src/executor.rs | 3 + crates/execution-config/src/call_from.rs | 4 +- crates/execution-config/src/chainspec.rs | 22 +- .../src/native_coin_control.rs | 2 +- crates/execution-e2e/CLAUDE.md | 42 + .../tests/beneficiary_blocklist.rs | 4 +- crates/execution-txpool/Cargo.toml | 2 + crates/execution-txpool/src/validator.rs | 200 ++++- crates/execution-validation/src/consensus.rs | 22 + .../malachite-app/src/handlers/finalized.rs | 18 +- .../src/handlers/started_round.rs | 87 ++- crates/malachite-app/src/lib.rs | 1 - crates/malachite-app/src/main.rs | 102 +++ crates/malachite-app/src/node.rs | 3 +- crates/malachite-app/src/state.rs | 14 +- crates/malachite-app/tests/cli_db_rollback.rs | 727 ++++++++++++++++++ crates/malachite-cli/src/cmd/db.rs | 21 + crates/node/Cargo.toml | 2 + crates/node/README.md | 14 +- crates/node/src/main.rs | 323 +++++++- crates/precompiles/Cargo.toml | 6 + crates/precompiles/benches/pq.rs | 93 +++ crates/precompiles/src/call_from.rs | 2 +- crates/precompiles/src/helpers.rs | 80 +- crates/precompiles/src/lib.rs | 164 ++-- crates/precompiles/src/macros.rs | 142 ++-- .../precompiles/src/native_coin_authority.rs | 190 ++++- crates/precompiles/src/native_coin_control.rs | 8 +- crates/precompiles/src/pq.rs | 11 +- crates/precompiles/src/precompile_provider.rs | 5 +- crates/precompiles/src/system_accounting.rs | 37 +- crates/quake/README.md | 19 +- crates/quake/scenarios/examples/arc-node.toml | 7 +- .../examples/testnet-small-default.toml | 1 - .../scenarios/examples/testnet-small.toml | 1 - crates/quake/scenarios/localdev.toml | 17 +- crates/quake/src/cli_version.rs | 408 ++++++++++ crates/quake/src/infra/ssm.rs | 59 +- crates/quake/src/main.rs | 1 + crates/quake/src/manifest.rs | 61 +- crates/quake/src/manifest/raw.rs | 2 +- crates/quake/src/setup.rs | 192 +++-- crates/quake/src/testnet.rs | 7 +- crates/spammer/src/erc20.rs | 6 +- crates/spammer/src/generator.rs | 20 +- crates/spammer/src/latency/tracker.rs | 30 +- crates/spammer/src/sender.rs | 7 +- crates/spammer/src/ws.rs | 13 + crates/test/checks/src/mev.rs | 2 +- crates/test/checks/src/perf.rs | 26 + crates/types/Cargo.toml | 2 + crates/types/src/lib.rs | 2 + crates/{malachite-app => types}/src/spec.rs | 343 ++++++--- docs/running-an-arc-node.md | 5 + scripts/ci/check-coverage-sharding.sh | 80 ++ scripts/ci/s3-cache-restore.sh | 76 ++ scripts/ci/s3-cache-save.sh | 44 ++ scripts/genesis/ProtocolConfig.ts | 37 +- scripts/genesis/addresses.ts | 7 +- scripts/hardhat/tasks/query-state.ts | 5 +- tests/helpers/networks/index.ts | 2 +- tests/helpers/networks/localdev.ts | 10 + tests/localdev/NativeFiatToken.test.ts | 22 +- tests/localdev/ProtocolConfig.test.ts | 39 +- tests/localdev/genesis.test.ts | 6 +- tests/localdev/per_validator_fees.test.ts | 91 +++ tests/simulation/ERC7201-migration.test.ts | 315 -------- tests/simulation/ProtocolConfig.test.ts | 55 +- .../simulation/ProtocolConfig.upgrade.test.ts | 46 +- 94 files changed, 3813 insertions(+), 1300 deletions(-) create mode 100644 crates/execution-e2e/CLAUDE.md create mode 100644 crates/malachite-app/tests/cli_db_rollback.rs create mode 100644 crates/precompiles/benches/pq.rs create mode 100644 crates/quake/src/cli_version.rs rename crates/{malachite-app => types}/src/spec.rs (51%) create mode 100755 scripts/ci/check-coverage-sharding.sh create mode 100755 scripts/ci/s3-cache-restore.sh create mode 100755 scripts/ci/s3-cache-save.sh create mode 100644 tests/localdev/per_validator_fees.test.ts delete mode 100644 tests/simulation/ERC7201-migration.test.ts diff --git a/.cargo/audit.toml b/.cargo/audit.toml index f1c4c40..478e9f4 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -24,4 +24,13 @@ ignore = [ # to 0.9+ requires a major-version API migration and transitive deps (yamux, libp2p) # still require rand 0.8.x "RUSTSEC-2026-0097", + + # hickory-proto 0.25.2 - NSEC3 closest-encloser proof unbounded loop; no fixed version + # available yet. Transitive dependency via reth-dns-discovery (reth v1.11.3) and + # libp2p-mdns (libp2p 0.56.0) — cannot be updated without bumping reth/libp2p. + "RUSTSEC-2026-0118", + # hickory-proto 0.25.2 - O(n²) name compression CPU exhaustion; fix requires >=0.26.1. + # 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", ] \ No newline at end of file diff --git a/.github/workflows/label-external-prs.yml b/.github/workflows/label-external-prs.yml index 4959641..68d2eae 100644 --- a/.github/workflows/label-external-prs.yml +++ b/.github/workflows/label-external-prs.yml @@ -9,14 +9,13 @@ on: branches: [main] permissions: - issues: write + pull-requests: write concurrency: group: label-external-pr-${{ github.event.pull_request.number }} cancel-in-progress: false - # cancel-in-progress: false is correct — label ops are idempotent but partial - # cancellation could leave a PR unlabeled. Two simultaneous merges both hit - # `gh label create --force`; one wins, the other is a no-op. + # Partial cancellation could leave a PR unlabeled; label application is + # idempotent so letting both runs finish is safe. jobs: label: @@ -31,16 +30,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Ensure label exists and apply + - name: Apply pending-import label env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} run: | set -euo pipefail - gh label create pending-import \ - --repo "${REPO}" \ - --color ededed \ - --description "Merged PR awaiting reverse-sync to upstream" \ - --force gh pr edit "${PR_NUMBER}" --repo "${REPO}" --add-label pending-import diff --git a/Cargo.lock b/Cargo.lock index 74ba18d..ab29b8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -934,6 +934,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -1061,6 +1067,7 @@ version = "0.0.1" dependencies = [ "alloy-consensus", "alloy-primitives", + "alloy-rlp", "alloy-rpc-types-engine", "arbitrary", "arc-malachitebft-app", @@ -1072,6 +1079,7 @@ dependencies = [ "arc-malachitebft-signing", "arc-malachitebft-signing-ed25519", "arc-malachitebft-sync", + "arc-shared", "arc-signer", "bytes", "bytesize", @@ -1382,12 +1390,14 @@ name = "arc-execution-txpool" version = "0.0.1" dependencies = [ "alloy-consensus", + "alloy-eips", "alloy-primitives", "arc-execution-config", "arc-execution-validation", "arc-precompiles", "arc-shared", "eyre", + "k256", "metrics", "parking_lot", "reth-chainspec", @@ -1876,6 +1886,7 @@ dependencies = [ "reth-node-metrics", "reth-prune-types", "reth-rpc-eth-types", + "reth-rpc-server-types", "revm", "revm-inspectors", "revm-primitives", @@ -1885,6 +1896,7 @@ dependencies = [ "tokio", "tonic-build", "tracing", + "tracing-test", ] [[package]] @@ -1897,6 +1909,7 @@ dependencies = [ "alloy-sol-types", "arc-evm", "arc-execution-config", + "criterion", "reth-chainspec", "reth-ethereum", "reth-ethereum-forks", @@ -1908,6 +1921,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sha2 0.10.9", "slh-dsa", "thiserror 2.0.18", ] @@ -3274,6 +3288,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -3380,6 +3400,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -3779,6 +3826,39 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -4379,7 +4459,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4684,7 +4764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5386,6 +5466,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "handlebars" version = "4.5.0" @@ -7730,7 +7821,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7932,6 +8023,12 @@ 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" @@ -8517,6 +8614,34 @@ dependencies = [ "crunchy", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "polling" version = "3.11.0" @@ -9213,7 +9338,7 @@ dependencies = [ "once_cell", "socket2 0.6.2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -12832,7 +12957,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -13972,7 +14097,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -14121,6 +14246,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -15295,7 +15430,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c7c154a..7246ef7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,7 @@ config = { version = "0.14", features = ["toml"], default-features = # get the wrong path, update this when the workflow has been updated # # See: https://github.com/eira-fransham/crunchy/issues/13 +criterion = "0.7" crunchy = "=0.2.2" csv = "1.4" deranged = "0.5.5" @@ -223,6 +224,7 @@ serde = { version = "1.0", default-features = false } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } serde_with = { version = "3", default-features = false, features = ["macros"] } serial_test = "3" +sha2 = "0.10.9" sha3 = "0.10.5" signature = "2.2.0" # Security fix for RUSTSEC-2025-0047 diff --git a/Makefile b/Makefile index e9942c4..7231ce2 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,11 @@ COV_FILE := target/lcov.info SCRIPTS="./scripts" FOUNDRY_VERSION := $(shell cat .foundry-version) QUAKE_MANIFEST ?= crates/quake/scenarios/localdev.toml -NUM_VALIDATORS := $(shell grep -c '^\[nodes\.validator' $(QUAKE_MANIFEST) 2>/dev/null || echo 5) +# Recursively expanded so smoke targets that override QUAKE_MANIFEST re-evaluate +# at recipe time. All localdev* scenarios must keep the same validator count +# (5) so `make smoke` produces a single genesis that matches both sub-targets. +NUM_VALIDATORS = $(shell grep -c '^\[nodes\.validator' $(QUAKE_MANIFEST) 2>/dev/null || echo 5) QUAKE := cargo run --bin quake -- -LOAD_PREDEFINED_ARC_REMOTE_SIGNER_KEYS := true DEFAULT_BRANCH ?= $(shell git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') ifeq ($(DEFAULT_BRANCH),) DEFAULT_BRANCH = main @@ -191,7 +193,8 @@ smoke: genesis ## Run smoke tests (both reth and malachite) @echo "All smoke tests completed successfully!" .PHONY: smoke-reth -smoke-reth: genesis ## Run Reth smoke tests +smoke-reth: export ARC_SMOKE_SCENARIO := reth +smoke-reth: genesis ## Run Reth smoke tests (mock CL, single fee recipient) @echo "Running smoke tests on local reth(mock CL)..." cargo build --release --bin arc-node-execution @bash -c '\ @@ -202,12 +205,13 @@ smoke-reth: genesis ## Run Reth smoke tests ' .PHONY: smoke-malachite -smoke-malachite: testnet ## Run Malachite smoke & Quake tests - @echo "Running smoke tests on local reth + malachite using testnet setup..." +smoke-malachite: export ARC_SMOKE_SCENARIO := malachite +smoke-malachite: testnet ## Run Malachite smoke tests (real CL, per-validator recipients) + @echo "Running smoke tests on local reth + malachite using testnet setup (localdev.toml)..." @bash -c '\ set -ex; \ trap "$(MAKE) testnet-clean" EXIT; \ - env LOAD_PREDEFINED_ARC_REMOTE_SIGNER_KEYS=$(LOAD_PREDEFINED_ARC_REMOTE_SIGNER_KEYS) $(MAKE) test-localdev; \ + $(MAKE) test-localdev; \ ' .PHONY: smoke-quake diff --git a/README.md b/README.md index ddcdcac..2c1d421 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,8 @@ security find-certificate -p -c '' > deployments/certs/.cr Interact with the testnet: ```bash -# Spam transactions -make testnet-spam +# Send tx load (usage: make testnet-load RATE=1000 TIME=60) +make testnet-load # Stop the testnet make testnet-down diff --git a/assets/devnet/genesis.config.ts b/assets/devnet/genesis.config.ts index d2cdef6..e5da0a8 100644 --- a/assets/devnet/genesis.config.ts +++ b/assets/devnet/genesis.config.ts @@ -68,7 +68,6 @@ const build = async () => { owner: creator.nextAccount('ProtocolConfig.owner', adminPrefund), controller: creator.nextAccount('ProtocolConfig.controller', adminPrefund), pauser: creator.nextAccount('ProtocolConfig.pauser', adminPrefund), - beneficiary: creator.nextAccount('ProtocolConfig.beneficiary'), feeParams: { alpha: 20n, kRate: 25n, diff --git a/assets/localdev/genesis.config.ts b/assets/localdev/genesis.config.ts index 4615e92..08c0cfe 100644 --- a/assets/localdev/genesis.config.ts +++ b/assets/localdev/genesis.config.ts @@ -16,9 +16,15 @@ import fs from 'fs' import { z } from 'zod' -import { parseEther, parseGwei, toHex, zeroAddress } from 'viem' +import { parseEther, parseGwei, toHex } from 'viem' import { privateKeyToAccount } from 'viem/accounts' -import { createBuilderContext, buildGenesis, GenesisConfig, schemaGenesisConfig, localdevFeeRecipient } from '../../scripts/genesis' +import { + createBuilderContext, + buildGenesis, + GenesisConfig, + schemaGenesisConfig, + localdevFeeRecipient, +} from '../../scripts/genesis' import { bigintReplacer } from '../../scripts/genesis/types' import { LocalDevAccountCreator } from '../../scripts/genesis/AccountCreator' @@ -104,8 +110,6 @@ const build = async (options: z.infer) => { owner: admin.address, controller: admin.address, pauser: admin.address, - // Zero = unset; EL honors CL-provided --suggested-fee-recipient per validator. - beneficiary: zeroAddress, feeParams: { alpha: 20n, // 20% kRate: 200n, // 2% diff --git a/assets/localdev/genesis.json b/assets/localdev/genesis.json index bb902d3..f4c5026 100644 --- a/assets/localdev/genesis.json +++ b/assets/localdev/genesis.json @@ -67,10 +67,10 @@ "nonce": "0x1", "code": "0x73fcff98b65f9ea559ec0df36f4072c7e3be0520df30146080604052600436106100355760003560e01c80636ccea6521461003a575b600080fd5b6101026004803603606081101561005057600080fd5b73ffffffffffffffffffffffffffffffffffffffff8235169160208101359181019060608101604082013564010000000081111561008d57600080fd5b82018360208201111561009f57600080fd5b803590602001918460018302840111640100000000831117156100c157600080fd5b91908080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250929550610116945050505050565b604080519115158252519081900360200190f35b600061012184610179565b610164578373ffffffffffffffffffffffffffffffffffffffff16610146848461017f565b73ffffffffffffffffffffffffffffffffffffffff16149050610172565b61016f848484610203565b90505b9392505050565b3b151590565b600081516041146101db576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260238152602001806106296023913960400191505060405180910390fd5b60208201516040830151606084015160001a6101f98682858561042d565b9695505050505050565b60008060608573ffffffffffffffffffffffffffffffffffffffff16631626ba7e60e01b86866040516024018083815260200180602001828103825283818151815260200191508051906020019080838360005b8381101561026f578181015183820152602001610257565b50505050905090810190601f16801561029c5780820380516001836020036101000a031916815260200191505b50604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08184030181529181526020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167fffffffff000000000000000000000000000000000000000000000000000000009098169790971787525181519196909550859450925090508083835b6020831061036957805182527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0909201916020918201910161032c565b6001836020036101000a038019825116818451168082178552505050505050905001915050600060405180830381855afa9150503d80600081146103c9576040519150601f19603f3d011682016040523d82523d6000602084013e6103ce565b606091505b50915091508180156103e257506020815110155b80156101f9575080517f1626ba7e00000000000000000000000000000000000000000000000000000000906020808401919081101561042057600080fd5b5051149695505050505050565b60007f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a08211156104a8576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260268152602001806106726026913960400191505060405180910390fd5b8360ff16601b141580156104c057508360ff16601c14155b15610516576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040180806020018281038252602681526020018061064c6026913960400191505060405180910390fd5b600060018686868660405160008152602001604052604051808581526020018460ff1681526020018381526020018281526020019450505050506020604051602081039080840390855afa158015610572573d6000803e3d6000fd5b50506040517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0015191505073ffffffffffffffffffffffffffffffffffffffff811661061f57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601c60248201527f45435265636f7665723a20696e76616c6964207369676e617475726500000000604482015290519081900360640190fd5b9594505050505056fe45435265636f7665723a20696e76616c6964207369676e6174757265206c656e67746845435265636f7665723a20696e76616c6964207369676e6174757265202776272076616c756545435265636f7665723a20696e76616c6964207369676e6174757265202773272076616c7565a2646970667358221220289705d6ae8701c9ed586541fa0ba342f754518aa4f0e59dbbda380bc9f2323964736f6c634300060c0033" }, - "0xcaC224cc7F15866F9454a22f735D0d4ae001D0a2": { + "0x1551f05F23614163494d2E58C0E5Dd0e402930E2": { "balance": "0x0", "nonce": "0x1", - "code": "0x608060405234801561000f575f5ffd5b5060043610610127575f3560e01c80638456cb59116100a95780639fd0506d1161006e5780639fd0506d14610420578063da980fed14610428578063e30c39781461043b578063f2fde38b14610443578063f77c479114610456575f5ffd5b80638456cb59146102165780638da5cb5b1461021e5780638e207848146102265780639242164f146102395780639fd02a361461033d575f5ffd5b806341a56c59116100ef57806341a56c5914610181578063554bab3c146101ca5780635c975abb146101dd578063715018a61461020657806379ba50971461020e575f5ffd5b8063032b901b1461012b57806306cb5b66146101405780632bbdb79f146101535780633466e3d4146101665780633f4ba83a14610179575b5f5ffd5b61013e610139366004610ec5565b61045e565b005b61013e61014e366004610ee7565b61050c565b61013e610161366004610f0d565b6105a3565b61013e610174366004610ee7565b6106b0565b61013e610739565b7f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385204546001600160a01b03165b6040516001600160a01b0390911681526020015b60405180910390f35b61013e6101d8366004610ee7565b610785565b5f5160206114105f395f51905f5254600160a01b900460ff1660405190151581526020016101c1565b61013e610809565b61013e61081c565b61013e610869565b6101ad6108bb565b61013e610234366004610f24565b6108ef565b6102d76040805160c0810182525f80825260208201819052918101829052606081018290526080810182905260a08101829052905f5160206113f05f395f51905f526040805160c08101825282546001600160401b038082168352600160401b820481166020840152600160801b9091041691810191909152600182015460608201526002820154608082015260039091015460a082015292915050565b6040516101c191905f60c0820190506001600160401b0383511682526001600160401b0360208401511660208301526001600160401b036040840151166040830152606083015160608301526080830151608083015260a083015160a083015292915050565b61041360408051610100810182525f80825260208201819052918101829052606081018290526080810182905260a0810182905260c0810182905260e08101829052905f5160206113f05f395f51905f52604080516101008101825260059092015461ffff80821684526201000082048116602085015264010000000082048116928401929092526601000000000000810482166060840152600160401b810482166080840152600160501b8104821660a0840152600160601b8104821660c0840152600160701b90041660e082015292915050565b6040516101c19190610f3d565b6101ad610a45565b61013e610436366004610fdd565b610a5a565b6101ad610c38565b61013e610451366004610ee7565b610c60565b6101ad610ce5565b610466610d0d565b61046e610d58565b5f8161ffff16116104925760405163811da5f160e01b815260040160405180910390fd5b7f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385205805461ffff191661ffff83161781556040515f5160206113f05f395f51905f52917faba8150f8eae6a1acc6824830944cdc19c670da2fae4fe28f4dfa04c0d6932d4916105009190610fef565b60405180910390a15050565b610514610d90565b6001600160a01b03811661053b576040516371ab47bb60e11b815260040160405180910390fd5b7f958f8fec699b51a1249f513eceda5429078000657f74abd1721bba363087af0080546001600160a01b0319166001600160a01b03831690811782556040517f1304018cfe79741dcf02ba6b61d39cc4757d59395d03224d9925c7aa83002146905f90a25050565b6105ab610d0d565b6105b3610d58565b5f81116105d357604051635251cbd760e01b815260040160405180910390fd5b7f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385203819055604080515f5160206113f05f395f51905f5280546001600160401b03808216845281851c81166020850152608091821c16938301939093527f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec843852015460608301527f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385202549282019290925260a081018390527f413a821e8d0eadcc30efbe627a655111b9f28f5999003a85b7718a14b0e329ef9060c001610500565b6106b8610d0d565b6106c0610d58565b7f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec8438520480546001600160a01b0319166001600160a01b0383169081179091556040515f5160206113f05f395f51905f5291907fdec90d8bfa3fe33f0b0d876fc2cfc0e936e625e503ed170de964f15e6e17d15c905f90a25050565b610741610dc2565b5f5160206114105f395f51905f52805460ff60a01b191681556040517f7805862f689e2f13df9f062ff482ad3ad112aca9e0847911ed832e158c525b33905f90a150565b61078d610d90565b6001600160a01b0381166107b45760405163a74995ab60e01b815260040160405180910390fd5b5f5160206114105f395f51905f5280546001600160a01b0319166001600160a01b03831690811782556040517fb80482a293ca2e013eda8683c9bd7fc8347cfdaeea5ede58cba46df502c2a604905f90a25050565b610811610d90565b61081a5f610dfa565b565b3380610826610c38565b6001600160a01b03161461085d5760405163118cdaa760e01b81526001600160a01b03821660048201526024015b60405180910390fd5b61086681610dfa565b50565b610871610dc2565b5f5160206114105f395f51905f52805460ff60a01b1916600160a01b1781556040517f6985a02210a168e66602d3235cb6db0e70f92b3ba4d376a33c0f3d9434bff625905f90a150565b5f807f9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c1993005b546001600160a01b031692915050565b6108f7610d0d565b6108ff610d58565b606461090e6020830183611077565b6001600160401b0316111561093657604051633e82ffd960e21b815260040160405180910390fd5b6127106109496040830160208401611077565b6001600160401b031611156109715760405163f87b4d6d60e01b815260040160405180910390fd5b80608001358160600135111561099a576040516336b1b98d60e11b815260040160405180910390fd5b5f8160a00135116109be57604051635251cbd760e01b815260040160405180910390fd5b6127106109d16060830160408401611077565b6001600160401b031611156109f95760405163279eca6760e21b815260040160405180910390fd5b5f5160206113f05f395f51905f528181610a138282611092565b9050507f413a821e8d0eadcc30efbe627a655111b9f28f5999003a85b7718a14b0e329ef82604051610500919061114f565b5f805f5160206114105f395f51905f526108df565b610a62610d0d565b610a6a610d58565b5f610a786020830183610ec5565b61ffff1611610a9a5760405163811da5f160e01b815260040160405180910390fd5b5f610aab6040830160208401610ec5565b61ffff1611610acd5760405163055dd8c360e01b815260040160405180910390fd5b5f610ade6060830160408401610ec5565b61ffff1611610b0057604051632de6d8ed60e11b815260040160405180910390fd5b5f610b116080830160608401610ec5565b61ffff1611610b3357604051634c14d46960e11b815260040160405180910390fd5b5f610b4460a0830160808401610ec5565b61ffff1611610b66576040516396e398e160e01b815260040160405180910390fd5b5f610b7760c0830160a08401610ec5565b61ffff1611610b9957604051632218f17b60e21b815260040160405180910390fd5b5f610baa60e0830160c08401610ec5565b61ffff1611610bcc57604051631805fa5160e11b815260040160405180910390fd5b5f5160206113f05f395f51905f52817f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385205610c0682826111d8565b9050507faba8150f8eae6a1acc6824830944cdc19c670da2fae4fe28f4dfa04c0d6932d482604051610500919061133e565b5f807f237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c006108df565b610c68610d90565b7f237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c0080546001600160a01b0319166001600160a01b0383169081178255610cac6108bb565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a35050565b5f807f958f8fec699b51a1249f513eceda5429078000657f74abd1721bba363087af006108df565b7f958f8fec699b51a1249f513eceda5429078000657f74abd1721bba363087af0080546001600160a01b0316331461086657604051630e971e6360e11b815260040160405180910390fd5b5f5160206114105f395f51905f528054600160a01b900460ff16156108665760405163ab35696f60e01b815260040160405180910390fd5b33610d996108bb565b6001600160a01b03161461081a5760405163118cdaa760e01b8152336004820152602401610854565b5f5160206114105f395f51905f5280546001600160a01b031633146108665760405163daeefd6560e01b815260040160405180910390fd5b7f237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c0080546001600160a01b0319168155610e3282610e36565b5050565b7f9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c19930080546001600160a01b031981166001600160a01b03848116918217845560405192169182907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0905f90a3505050565b61ffff81168114610866575f5ffd5b8035610ec081610ea6565b919050565b5f60208284031215610ed5575f5ffd5b8135610ee081610ea6565b9392505050565b5f60208284031215610ef7575f5ffd5b81356001600160a01b0381168114610ee0575f5ffd5b5f60208284031215610f1d575f5ffd5b5035919050565b5f60c0828403128015610f35575f5ffd5b509092915050565b5f6101008201905061ffff835116825261ffff602084015116602083015261ffff60408401511660408301526060830151610f7e606084018261ffff169052565b506080830151610f94608084018261ffff169052565b5060a0830151610faa60a084018261ffff169052565b5060c0830151610fc060c084018261ffff169052565b5060e0830151610fd660e084018261ffff169052565b5092915050565b5f610100828403128015610f35575f5ffd5b815461ffff8082168352601082901c166020830152610100820190602081901c61ffff166040840152603081901c61ffff166060840152604081901c61ffff166080840152605081901c61ffff1660a0840152606081901c61ffff1660c0840152607081901c61ffff1660e0840152610fd6565b6001600160401b0381168114610866575f5ffd5b5f60208284031215611087575f5ffd5b8135610ee081611063565b813561109d81611063565b6001600160401b03811690508154816001600160401b0319821617835560208401356110c881611063565b6fffffffffffffffff00000000000000008160401b16836fffffffffffffffffffffffffffffffff198416171784555050505f604083013561110981611063565b825467ffffffffffffffff60801b1916608091821b67ffffffffffffffff60801b161783556060840135600184015583013560028301555060a090910135600390910155565b60c08101823561115e81611063565b6001600160401b03168252602083013561117781611063565b6001600160401b03166020830152604083013561119381611063565b6001600160401b03166040830152606083810135908301526080808401359083015260a092830135929091019190915290565b5f81356111d281610ea6565b92915050565b81356111e381610ea6565b61ffff8116905081548161ffff198216178355602084013561120481610ea6565b63ffff00008160101b168363ffffffff1984161717845550505061124b61122d604084016111c6565b825465ffff00000000191660209190911b65ffff0000000016178255565b61127c61125a606084016111c6565b825467ffff000000000000191660309190911b67ffff00000000000016178255565b6112b161128b608084016111c6565b825469ffff0000000000000000191660409190911b69ffff000000000000000016178255565b6112e06112c060a084016111c6565b82805461ffff60501b191660509290921b61ffff60501b16919091179055565b61130f6112ef60c084016111c6565b82805461ffff60601b191660609290921b61ffff60601b16919091179055565b610e3261131e60e084016111c6565b82805461ffff60701b191660709290921b61ffff60701b16919091179055565b6101008101823561134e81610ea6565b61ffff168252602083013561136281610ea6565b61ffff16602083015261137760408401610eb5565b61ffff16604083015261138c60608401610eb5565b61ffff1660608301526113a160808401610eb5565b61ffff1660808301526113b660a08401610eb5565b61ffff1660a08301526113cb60c08401610eb5565b61ffff1660c08301526113e060e08401610eb5565b61ffff811660e0840152610fd656fe668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec843852000642d7922329a434cf4fd17a3c95eb692c24fd95f9f94d0b55420a5d895f4a00a2646970667358221220477a036c9ec804860715e0657fe49c602f65271f42c96af40095bcf35a04f75164736f6c634300081d0033" + "code": "0x608060405234801561000f575f5ffd5b5060043610610111575f3560e01c80638da5cb5b1161009e5780639fd0506d1161006e5780639fd0506d146103cb578063da980fed146103d3578063e30c3978146103e6578063f2fde38b146103ee578063f77c479114610401575f5ffd5b80638da5cb5b146101b15780638e207848146101d15780639242164f146101e45780639fd02a36146102e8575f5ffd5b8063554bab3c116100e4578063554bab3c146101585780635c975abb1461016b578063715018a61461019957806379ba5097146101a15780638456cb59146101a9575f5ffd5b8063032b901b1461011557806306cb5b661461012a5780632bbdb79f1461013d5780633f4ba83a14610150575b5f5ffd5b610128610123366004610de7565b610409565b005b610128610138366004610e09565b6104b7565b61012861014b366004610e2f565b61054e565b61012861065b565b610128610166366004610e09565b6106a7565b5f5160206113325f395f51905f5254600160a01b900460ff1660405190151581526020015b60405180910390f35b61012861072b565b61012861073e565b61012861078b565b6101b96107dd565b6040516001600160a01b039091168152602001610190565b6101286101df366004610e46565b610811565b6102826040805160c0810182525f80825260208201819052918101829052606081018290526080810182905260a08101829052905f5160206113125f395f51905f526040805160c08101825282546001600160401b038082168352600160401b820481166020840152600160801b9091041691810191909152600182015460608201526002820154608082015260039091015460a082015292915050565b60405161019091905f60c0820190506001600160401b0383511682526001600160401b0360208401511660208301526001600160401b036040840151166040830152606083015160608301526080830151608083015260a083015160a083015292915050565b6103be60408051610100810182525f80825260208201819052918101829052606081018290526080810182905260a0810182905260c0810182905260e08101829052905f5160206113125f395f51905f52604080516101008101825260059092015461ffff80821684526201000082048116602085015264010000000082048116928401929092526601000000000000810482166060840152600160401b810482166080840152600160501b8104821660a0840152600160601b8104821660c0840152600160701b90041660e082015292915050565b6040516101909190610e5f565b6101b9610967565b6101286103e1366004610eff565b61097c565b6101b9610b5a565b6101286103fc366004610e09565b610b82565b6101b9610c07565b610411610c2f565b610419610c7a565b5f8161ffff161161043d5760405163811da5f160e01b815260040160405180910390fd5b7f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385205805461ffff191661ffff83161781556040515f5160206113125f395f51905f52917faba8150f8eae6a1acc6824830944cdc19c670da2fae4fe28f4dfa04c0d6932d4916104ab9190610f11565b60405180910390a15050565b6104bf610cb2565b6001600160a01b0381166104e6576040516371ab47bb60e11b815260040160405180910390fd5b7f958f8fec699b51a1249f513eceda5429078000657f74abd1721bba363087af0080546001600160a01b0319166001600160a01b03831690811782556040517f1304018cfe79741dcf02ba6b61d39cc4757d59395d03224d9925c7aa83002146905f90a25050565b610556610c2f565b61055e610c7a565b5f811161057e57604051635251cbd760e01b815260040160405180910390fd5b7f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385203819055604080515f5160206113125f395f51905f5280546001600160401b03808216845281851c81166020850152608091821c16938301939093527f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec843852015460608301527f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385202549282019290925260a081018390527f413a821e8d0eadcc30efbe627a655111b9f28f5999003a85b7718a14b0e329ef9060c0016104ab565b610663610ce4565b5f5160206113325f395f51905f52805460ff60a01b191681556040517f7805862f689e2f13df9f062ff482ad3ad112aca9e0847911ed832e158c525b33905f90a150565b6106af610cb2565b6001600160a01b0381166106d65760405163a74995ab60e01b815260040160405180910390fd5b5f5160206113325f395f51905f5280546001600160a01b0319166001600160a01b03831690811782556040517fb80482a293ca2e013eda8683c9bd7fc8347cfdaeea5ede58cba46df502c2a604905f90a25050565b610733610cb2565b61073c5f610d1c565b565b3380610748610b5a565b6001600160a01b03161461077f5760405163118cdaa760e01b81526001600160a01b03821660048201526024015b60405180910390fd5b61078881610d1c565b50565b610793610ce4565b5f5160206113325f395f51905f52805460ff60a01b1916600160a01b1781556040517f6985a02210a168e66602d3235cb6db0e70f92b3ba4d376a33c0f3d9434bff625905f90a150565b5f807f9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c1993005b546001600160a01b031692915050565b610819610c2f565b610821610c7a565b60646108306020830183610f99565b6001600160401b0316111561085857604051633e82ffd960e21b815260040160405180910390fd5b61271061086b6040830160208401610f99565b6001600160401b031611156108935760405163f87b4d6d60e01b815260040160405180910390fd5b8060800135816060013511156108bc576040516336b1b98d60e11b815260040160405180910390fd5b5f8160a00135116108e057604051635251cbd760e01b815260040160405180910390fd5b6127106108f36060830160408401610f99565b6001600160401b0316111561091b5760405163279eca6760e21b815260040160405180910390fd5b5f5160206113125f395f51905f5281816109358282610fb4565b9050507f413a821e8d0eadcc30efbe627a655111b9f28f5999003a85b7718a14b0e329ef826040516104ab9190611071565b5f805f5160206113325f395f51905f52610801565b610984610c2f565b61098c610c7a565b5f61099a6020830183610de7565b61ffff16116109bc5760405163811da5f160e01b815260040160405180910390fd5b5f6109cd6040830160208401610de7565b61ffff16116109ef5760405163055dd8c360e01b815260040160405180910390fd5b5f610a006060830160408401610de7565b61ffff1611610a2257604051632de6d8ed60e11b815260040160405180910390fd5b5f610a336080830160608401610de7565b61ffff1611610a5557604051634c14d46960e11b815260040160405180910390fd5b5f610a6660a0830160808401610de7565b61ffff1611610a88576040516396e398e160e01b815260040160405180910390fd5b5f610a9960c0830160a08401610de7565b61ffff1611610abb57604051632218f17b60e21b815260040160405180910390fd5b5f610acc60e0830160c08401610de7565b61ffff1611610aee57604051631805fa5160e11b815260040160405180910390fd5b5f5160206113125f395f51905f52817f668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385205610b2882826110fa565b9050507faba8150f8eae6a1acc6824830944cdc19c670da2fae4fe28f4dfa04c0d6932d4826040516104ab9190611260565b5f807f237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c00610801565b610b8a610cb2565b7f237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c0080546001600160a01b0319166001600160a01b0383169081178255610bce6107dd565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a35050565b5f807f958f8fec699b51a1249f513eceda5429078000657f74abd1721bba363087af00610801565b7f958f8fec699b51a1249f513eceda5429078000657f74abd1721bba363087af0080546001600160a01b0316331461078857604051630e971e6360e11b815260040160405180910390fd5b5f5160206113325f395f51905f528054600160a01b900460ff16156107885760405163ab35696f60e01b815260040160405180910390fd5b33610cbb6107dd565b6001600160a01b03161461073c5760405163118cdaa760e01b8152336004820152602401610776565b5f5160206113325f395f51905f5280546001600160a01b031633146107885760405163daeefd6560e01b815260040160405180910390fd5b7f237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c0080546001600160a01b0319168155610d5482610d58565b5050565b7f9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c19930080546001600160a01b031981166001600160a01b03848116918217845560405192169182907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0905f90a3505050565b61ffff81168114610788575f5ffd5b8035610de281610dc8565b919050565b5f60208284031215610df7575f5ffd5b8135610e0281610dc8565b9392505050565b5f60208284031215610e19575f5ffd5b81356001600160a01b0381168114610e02575f5ffd5b5f60208284031215610e3f575f5ffd5b5035919050565b5f60c0828403128015610e57575f5ffd5b509092915050565b5f6101008201905061ffff835116825261ffff602084015116602083015261ffff60408401511660408301526060830151610ea0606084018261ffff169052565b506080830151610eb6608084018261ffff169052565b5060a0830151610ecc60a084018261ffff169052565b5060c0830151610ee260c084018261ffff169052565b5060e0830151610ef860e084018261ffff169052565b5092915050565b5f610100828403128015610e57575f5ffd5b815461ffff8082168352601082901c166020830152610100820190602081901c61ffff166040840152603081901c61ffff166060840152604081901c61ffff166080840152605081901c61ffff1660a0840152606081901c61ffff1660c0840152607081901c61ffff1660e0840152610ef8565b6001600160401b0381168114610788575f5ffd5b5f60208284031215610fa9575f5ffd5b8135610e0281610f85565b8135610fbf81610f85565b6001600160401b03811690508154816001600160401b031982161783556020840135610fea81610f85565b6fffffffffffffffff00000000000000008160401b16836fffffffffffffffffffffffffffffffff198416171784555050505f604083013561102b81610f85565b825467ffffffffffffffff60801b1916608091821b67ffffffffffffffff60801b161783556060840135600184015583013560028301555060a090910135600390910155565b60c08101823561108081610f85565b6001600160401b03168252602083013561109981610f85565b6001600160401b0316602083015260408301356110b581610f85565b6001600160401b03166040830152606083810135908301526080808401359083015260a092830135929091019190915290565b5f81356110f481610dc8565b92915050565b813561110581610dc8565b61ffff8116905081548161ffff198216178355602084013561112681610dc8565b63ffff00008160101b168363ffffffff1984161717845550505061116d61114f604084016110e8565b825465ffff00000000191660209190911b65ffff0000000016178255565b61119e61117c606084016110e8565b825467ffff000000000000191660309190911b67ffff00000000000016178255565b6111d36111ad608084016110e8565b825469ffff0000000000000000191660409190911b69ffff000000000000000016178255565b6112026111e260a084016110e8565b82805461ffff60501b191660509290921b61ffff60501b16919091179055565b61123161121160c084016110e8565b82805461ffff60601b191660609290921b61ffff60601b16919091179055565b610d5461124060e084016110e8565b82805461ffff60701b191660709290921b61ffff60701b16919091179055565b6101008101823561127081610dc8565b61ffff168252602083013561128481610dc8565b61ffff16602083015261129960408401610dd7565b61ffff1660408301526112ae60608401610dd7565b61ffff1660608301526112c360808401610dd7565b61ffff1660808301526112d860a08401610dd7565b61ffff1660a08301526112ed60c08401610dd7565b61ffff1660c083015261130260e08401610dd7565b61ffff811660e0840152610ef856fe668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec843852000642d7922329a434cf4fd17a3c95eb692c24fd95f9f94d0b55420a5d895f4a00a2646970667358221220300b20da9342d83646d128d553cf6c6bd30c424da33ceef743adc9013f59da1264736f6c634300081d0033" }, "0x3600000000000000000000000000000000000001": { "balance": "0x0", @@ -78,7 +78,7 @@ "code": "0x608060405260043610610049575f3560e01c80633659cfe6146100535780634f1ef286146100725780635c60da1b146100855780638f283970146100b5578063f851a440146100d4575b6100516100e8565b005b34801561005e575f5ffd5b5061005161006d3660046104f3565b6100fa565b61005161008036600461050c565b610134565b348015610090575f5ffd5b50610099610197565b6040516001600160a01b03909116815260200160405180910390f35b3480156100c0575f5ffd5b506100516100cf3660046104f3565b6101a5565b3480156100df575f5ffd5b506100996101c5565b6100f86100f3610197565b6101ce565b565b6101026101ec565b6001600160a01b0316330361012c576101298160405180602001604052805f81525061021e565b50565b6101296100e8565b61013c6101ec565b6001600160a01b0316330361018f5761018a8383838080601f0160208091040260200160405190810160405280939291908181526020018383808284375f9201919091525061021e92505050565b505050565b61018a6100e8565b5f6101a0610277565b905090565b6101ad6101ec565b6001600160a01b0316330361012c576101298161029e565b5f6101a06101ec565b365f5f375f5f365f845af43d5f5f3e8080156101e8573d5ff35b3d5ffd5b5f7fb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d61035b546001600160a01b0316919050565b610227826102f2565b6040516001600160a01b038316907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b905f90a280511561026b5761018a8282610370565b6102736103e2565b5050565b5f7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc61020f565b7f7e644d79422f17c01e4894b5f4f588d331ebfa28653d42ae832dc59e38c9798f6102c76101ec565b604080516001600160a01b03928316815291841660208301520160405180910390a161012981610401565b806001600160a01b03163b5f0361032c57604051634c9c8ce360e01b81526001600160a01b03821660048201526024015b60405180910390fd5b807f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5b80546001600160a01b0319166001600160a01b039290921691909117905550565b60605f5f846001600160a01b03168460405161038c919061058a565b5f60405180830381855af49150503d805f81146103c4576040519150601f19603f3d011682016040523d82523d5f602084013e6103c9565b606091505b50915091506103d9858383610451565b95945050505050565b34156100f85760405163b398979f60e01b815260040160405180910390fd5b6001600160a01b03811661042a57604051633173bdd160e11b81525f6004820152602401610323565b807fb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d610361034f565b60608261046657610461826104b0565b6104a9565b815115801561047d57506001600160a01b0384163b155b156104a657604051639996b31560e01b81526001600160a01b0385166004820152602401610323565b50805b9392505050565b8051156104bf57805160208201fd5b60405163d6bda27560e01b815260040160405180910390fd5b80356001600160a01b03811681146104ee575f5ffd5b919050565b5f60208284031215610503575f5ffd5b6104a9826104d8565b5f5f5f6040848603121561051e575f5ffd5b610527846104d8565b9250602084013567ffffffffffffffff811115610542575f5ffd5b8401601f81018613610552575f5ffd5b803567ffffffffffffffff811115610568575f5ffd5b866020828401011115610579575f5ffd5b939660209190910195509293505050565b5f82518060208501845e5f92019182525091905056fea26469706673582212204c0881af9b906ca964ce2bc1cc4525350de8dd57658dacb806266290556eb9ae64736f6c634300081d0033", "storage": { "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103": "0x000000000000000000000000a0ee7a142d267c1f36714e4a8f75612f20a79720", - "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc": "0x000000000000000000000000cac224cc7f15866f9454a22f735d0d4ae001d0a2", + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc": "0x0000000000000000000000001551f05f23614163494d2e58c0e5dd0e402930e2", "0x9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c199300": "0x00000000000000000000000023618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f", "0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00": "0x0000000000000000000000000000000000000000000000000000000000000001", "0x958f8fec699b51a1249f513eceda5429078000657f74abd1721bba363087af00": "0x00000000000000000000000023618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f", @@ -87,7 +87,6 @@ "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385201": "0x0000000000000000000000000000000000000000000000000000000000000001", "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385202": "0x000000000000000000000000000000000000000000000000000000e8d4a51000", "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385203": "0x0000000000000000000000000000000000000000000000000000000001c9c380", - "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385204": "0x0000000000000000000000000000000000000000000000000000000000000000", "0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385205": "0x0000000000000000000000000000000001f403e801f403e801f403e801f40bb8" } }, @@ -226,15 +225,15 @@ "nonce": "0x1", "code": "0x60806040526004361061008f575f3560e01c806386fdb4d31161005757806386fdb4d314610137578063c1ee9d5e1461014c578063caeda9a614610161578063cb1ed51814610180578063fa0ff39f14610193575f5ffd5b806332e43a111461009357806357043888146100b95780635e666e4a146100ce578063665c5b98146100f95780636715131914610118575b5f5ffd5b34801561009e575f5ffd5b506100a75f5481565b60405190815260200160405180910390f35b3480156100c4575f5ffd5b506100a760025481565b3480156100d9575f5ffd5b506100a76100e83660046103fb565b60016020525f908152604090205481565b348015610104575f5ffd5b506100a76101133660046103fb565b6101b1565b348015610123575f5ffd5b506100a76101323660046103fb565b61023d565b61014a6101453660046103fb565b610284565b005b348015610157575f5ffd5b506100a760035481565b34801561016c575f5ffd5b506100a761017b3660046103fb565b61034d565b61014a61018e3660046103fb565b6103db565b34801561019e575f5ffd5b5061014a6101ad3660046103fb565b5f55565b5f815f036101c057505f919050565b6002546040516bffffffffffffffffffffffff193360601b16602082015260348101919091525f9060540160408051601f19818403018152919052805160209091012090505f5b8381101561022e578181015f90815260016020819052604090912054939093189201610207565b50506002805490920190915590565b5f805b8281101561027e57604080516020810184905290810182905260600160408051601f1981840301815291905280516020909101209150600101610240565b50919050565b5f30825a6102929190610426565b604080514260248083019190915282518083039091018152604490910182526020810180516001600160e01b031663fa0ff39f60e01b17905290516102d7919061043f565b5f604051808303818686fa925050503d805f8114610310576040519150601f19603f3d011682016040523d82523d5f602084013e610315565b606091505b505090505f81610325575f610328565b60015b60ff1690505b825a1115610348578061034081610455565b91505061032e565b505050565b5f815f0361035c57505f919050565b6003546040516bffffffffffffffffffffffff193360601b16602082015260348101919091525f9060540160408051601f19818403018152919052805160209091012090505f5b838110156103cc578181015f8181526001602081905260409091209490911893849055016103a3565b50506003805490920190915590565b5f5b815a11156103f757806103ef81610455565b9150506103dd565b5050565b5f6020828403121561040b575f5ffd5b5035919050565b634e487b7160e01b5f52601160045260245ffd5b8181038181111561043957610439610412565b92915050565b5f82518060208501845e5f920191825250919050565b5f6001820161046657610466610412565b506001019056fea26469706673582212202b8f1c900a394275a0d8f46b1573db0b518b0424e872e3ddb6309e56e5730c7764736f6c634300081d0033" }, - "0xe4aa7Ed3585AEf598179f873086F75Fcd6D4b755": { + "0x5294E9927c3306DcBaDb03fe70b92e01cCede505": { "balance": "0x0", "nonce": "0x1", - "code": "0x608060405234801561000f575f5ffd5b506004361061003f575f3560e01c8063c3b2c4f814610043578063ea94cddd14610058578063f884e35514610083575b5f5ffd5b610056610051366004610231565b610099565b005b61006660036003609b1b0181565b6040516001600160a01b0390911681526020015b60405180910390f35b61008b5f5481565b60405190815260200161007a565b5f805481806100a7836102c7565b9091555060405190915081907fb252e055da754c72fbf7542cf424b190808a9b541e912894c5e15b4238c41501905f90a2604051631595ec0b60e01b81525f90819060036003609b1b0190631595ec0b9061010c9033908d908d908d90600401610313565b5f604051808303815f875af1158015610127573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f1916820160405261014e919081019061035d565b915091508161017b578060405163768cb35160e11b81526004016101729190610428565b60405180910390fd5b85896001600160a01b0316336001600160a01b03167feb15ee720798341c37739df41be53acfbbf70ae6802dade35457beec6e47a5e48b8b6040516101c192919061045d565b6040519081900381206101d9918b908b908b9061046c565b60405180910390a4505050505050505050565b5f5f83601f8401126101fc575f5ffd5b50813567ffffffffffffffff811115610213575f5ffd5b60208301915083602082850101111561022a575f5ffd5b9250929050565b5f5f5f5f5f5f60808789031215610246575f5ffd5b86356001600160a01b038116811461025c575f5ffd5b9550602087013567ffffffffffffffff811115610277575f5ffd5b61028389828a016101ec565b90965094505060408701359250606087013567ffffffffffffffff8111156102a9575f5ffd5b6102b589828a016101ec565b979a9699509497509295939492505050565b5f600182016102e457634e487b7160e01b5f52601160045260245ffd5b5060010190565b81835281816020850137505f828201602090810191909152601f909101601f19169091010190565b6001600160a01b038581168252841660208201526060604082018190525f9061033f90830184866102eb565b9695505050505050565b634e487b7160e01b5f52604160045260245ffd5b5f5f6040838503121561036e575f5ffd5b8251801515811461037d575f5ffd5b602084015190925067ffffffffffffffff811115610399575f5ffd5b8301601f810185136103a9575f5ffd5b805167ffffffffffffffff8111156103c3576103c3610349565b604051601f8201601f19908116603f0116810167ffffffffffffffff811182821017156103f2576103f2610349565b604052818152828201602001871015610409575f5ffd5b8160208401602083015e5f602083830101528093505050509250929050565b602081525f82518060208401528060208501604085015e5f604082850101526040601f19601f83011684010191505092915050565b818382375f9101908152919050565b848152606060208201525f6104856060830185876102eb565b90508260408301529594505050505056fea264697066735822122055e34761f9688c46910c2312ec9a6b566f67e946c47cd1aa4f5cca217b38341364736f6c634300081d0033" + "code": "0x608060405234801561000f575f5ffd5b506004361061003f575f3560e01c8063c3b2c4f814610043578063ea94cddd14610058578063f884e35514610083575b5f5ffd5b610056610051366004610231565b610099565b005b61006660036003609b1b0181565b6040516001600160a01b0390911681526020015b60405180910390f35b61008b5f5481565b60405190815260200161007a565b5f805481806100a7836102c7565b9091555060405190915081907fb252e055da754c72fbf7542cf424b190808a9b541e912894c5e15b4238c41501905f90a2604051631595ec0b60e01b81525f90819060036003609b1b0190631595ec0b9061010c9033908d908d908d90600401610313565b5f604051808303815f875af1158015610127573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f1916820160405261014e919081019061035d565b915091508161017b578060405163768cb35160e11b81526004016101729190610428565b60405180910390fd5b85896001600160a01b0316336001600160a01b03167feb15ee720798341c37739df41be53acfbbf70ae6802dade35457beec6e47a5e48b8b6040516101c192919061045d565b6040519081900381206101d9918b908b908b9061046c565b60405180910390a4505050505050505050565b5f5f83601f8401126101fc575f5ffd5b50813567ffffffffffffffff811115610213575f5ffd5b60208301915083602082850101111561022a575f5ffd5b9250929050565b5f5f5f5f5f5f60808789031215610246575f5ffd5b86356001600160a01b038116811461025c575f5ffd5b9550602087013567ffffffffffffffff811115610277575f5ffd5b61028389828a016101ec565b90965094505060408701359250606087013567ffffffffffffffff8111156102a9575f5ffd5b6102b589828a016101ec565b979a9699509497509295939492505050565b5f600182016102e457634e487b7160e01b5f52601160045260245ffd5b5060010190565b81835281816020850137505f828201602090810191909152601f909101601f19169091010190565b6001600160a01b038581168252841660208201526060604082018190525f9061033f90830184866102eb565b9695505050505050565b634e487b7160e01b5f52604160045260245ffd5b5f5f6040838503121561036e575f5ffd5b8251801515811461037d575f5ffd5b602084015190925067ffffffffffffffff811115610399575f5ffd5b8301601f810185136103a9575f5ffd5b805167ffffffffffffffff8111156103c3576103c3610349565b604051601f8201601f19908116603f0116810167ffffffffffffffff811182821017156103f2576103f2610349565b604052818152828201602001871015610409575f5ffd5b8160208401602083015e5f602083830101528093505050509250929050565b602081525f82518060208401528060208501604085015e5f604082850101526040601f19601f83011684010191505092915050565b818382375f9101908152919050565b848152606060208201525f6104856060830185876102eb565b90508260408301529594505050505056fea2646970667358221220dc659493b17f8025af73b0b01138f0edcef92362d7b72a66dced7d061709f59964736f6c634300081d0033" }, - "0x825F535677d346626cDE45D64cf89C2a426467e0": { + "0xA3E6c63b16321E39a61551Dc1A38689b04d62E42": { "balance": "0x0", "nonce": "0x1", - "code": "0x608060405234801561000f575f5ffd5b50600436106100fb575f3560e01c806372425d9d11610093578063bce38bd711610063578063bce38bd7146101d4578063c3077fa9146101e7578063ea94cddd146101fa578063ee82ac5e14610208575f5ffd5b806372425d9d1461018e57806382ad56cb1461019457806386d516e8146101b4578063a8b0574e146101ba575f5ffd5b8063399542e9116100ce578063399542e9146101455780633e64a6961461016757806342cbb15c1461016d5780634d2301cc14610173575f5ffd5b80630f28c97d146100ff578063252dba421461011457806327e86d6e146101355780633408e4701461013f575b5f5ffd5b425b6040519081526020015b60405180910390f35b61012761012236600461093d565b61021a565b60405161010b9291906109aa565b435f190140610101565b46610101565b610158610153366004610a24565b61038e565b60405161010b93929190610ae8565b48610101565b43610101565b610101610181366004610b0f565b6001600160a01b03163190565b44610101565b6101a76101a236600461093d565b6103ac565b60405161010b9190610b3c565b45610101565b415b6040516001600160a01b03909116815260200161010b565b6101a76101e2366004610a24565b610586565b6101586101f536600461093d565b61072c565b6101bc60036003609b1b0181565b610101610216366004610b4e565b4090565b436060828067ffffffffffffffff81111561023757610237610b65565b60405190808252806020026020018201604052801561026a57816020015b60608152602001906001900390816102555790505b5091505f5b81811015610385575f8060036003609b1b01631595ec0b338a8a8781811061029957610299610b79565b90506020028101906102ab9190610b8d565b6102b9906020810190610b0f565b8b8b888181106102cb576102cb610b79565b90506020028101906102dd9190610b8d565b6102eb906020810190610bab565b6040518563ffffffff1660e01b815260040161030a9493929190610bee565b5f604051808303815f875af1158015610325573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f1916820160405261034c9190810190610c36565b915091508161035d57805160208201fd5b8085848151811061037057610370610b79565b6020908102919091010152505060010161026f565b50509250929050565b5f5f606061039d86868661074a565b91989097509095509350505050565b6060818067ffffffffffffffff8111156103c8576103c8610b65565b60405190808252806020026020018201604052801561040d57816020015b604080518082019091525f8152606060208201528152602001906001900390816103e65790505b5091505f5b8181101561057e575f8060036003609b1b01631595ec0b3389898781811061043c5761043c610b79565b905060200281019061044e9190610cfd565b61045c906020810190610b0f565b8a8a8881811061046e5761046e610b79565b90506020028101906104809190610cfd565b61048e906040810190610bab565b6040518563ffffffff1660e01b81526004016104ad9493929190610bee565b5f604051808303815f875af11580156104c8573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526104ef9190810190610c36565b91509150604051806040016040528083151581526020018281525085848151811061051c5761051c610b79565b602002602001018190525086868481811061053957610539610b79565b905060200281019061054b9190610cfd565b61055c906040810190602001610d11565b158015610567575081155b1561057457805160208201fd5b5050600101610412565b505092915050565b6060818067ffffffffffffffff8111156105a2576105a2610b65565b6040519080825280602002602001820160405280156105e757816020015b604080518082019091525f8152606060208201528152602001906001900390816105c05790505b5091505f5b81811015610723575f8060036003609b1b01631595ec0b3389898781811061061657610616610b79565b90506020028101906106289190610b8d565b610636906020810190610b0f565b8a8a8881811061064857610648610b79565b905060200281019061065a9190610b8d565b610668906020810190610bab565b6040518563ffffffff1660e01b81526004016106879493929190610bee565b5f604051808303815f875af11580156106a2573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526106c99190810190610c36565b915091508780156106d8575081155b156106e557805160208201fd5b604051806040016040528083151581526020018281525085848151811061070e5761070e610b79565b602090810291909101015250506001016105ec565b50509392505050565b5f5f606061073c6001868661074a565b919790965090945092505050565b4380406060838067ffffffffffffffff81111561076957610769610b65565b6040519080825280602002602001820160405280156107ae57816020015b604080518082019091525f8152606060208201528152602001906001900390816107875790505b5091505f5b818110156108ea575f8060036003609b1b01631595ec0b338b8b878181106107dd576107dd610b79565b90506020028101906107ef9190610b8d565b6107fd906020810190610b0f565b8c8c8881811061080f5761080f610b79565b90506020028101906108219190610b8d565b61082f906020810190610bab565b6040518563ffffffff1660e01b815260040161084e9493929190610bee565b5f604051808303815f875af1158015610869573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526108909190810190610c36565b9150915089801561089f575081155b156108ac57805160208201fd5b60405180604001604052808315158152602001828152508584815181106108d5576108d5610b79565b602090810291909101015250506001016107b3565b505093509350939050565b5f5f83601f840112610905575f5ffd5b50813567ffffffffffffffff81111561091c575f5ffd5b6020830191508360208260051b8501011115610936575f5ffd5b9250929050565b5f5f6020838503121561094e575f5ffd5b823567ffffffffffffffff811115610964575f5ffd5b610970858286016108f5565b90969095509350505050565b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b5f604082018483526040602084015280845180835260608501915060608160051b8601019250602086015f5b82811015610a0757605f198786030184526109f285835161097c565b945060209384019391909101906001016109d6565b5092979650505050505050565b8015158114610a21575f5ffd5b50565b5f5f5f60408486031215610a36575f5ffd5b8335610a4181610a14565b9250602084013567ffffffffffffffff811115610a5c575f5ffd5b610a68868287016108f5565b9497909650939450505050565b5f82825180855260208501945060208160051b830101602085015f5b83811015610adc57601f1985840301885281518051151584526020810151905060406020850152610ac5604085018261097c565b6020998a0199909450929092019150600101610a91565b50909695505050505050565b838152826020820152606060408201525f610b066060830184610a75565b95945050505050565b5f60208284031215610b1f575f5ffd5b81356001600160a01b0381168114610b35575f5ffd5b9392505050565b602081525f610b356020830184610a75565b5f60208284031215610b5e575f5ffd5b5035919050565b634e487b7160e01b5f52604160045260245ffd5b634e487b7160e01b5f52603260045260245ffd5b5f8235603e19833603018112610ba1575f5ffd5b9190910192915050565b5f5f8335601e19843603018112610bc0575f5ffd5b83018035915067ffffffffffffffff821115610bda575f5ffd5b602001915036819003821315610936575f5ffd5b6001600160a01b038581168252841660208201526060604082018190528101829052818360808301375f818301608090810191909152601f909201601f191601019392505050565b5f5f60408385031215610c47575f5ffd5b8251610c5281610a14565b602084015190925067ffffffffffffffff811115610c6e575f5ffd5b8301601f81018513610c7e575f5ffd5b805167ffffffffffffffff811115610c9857610c98610b65565b604051601f8201601f19908116603f0116810167ffffffffffffffff81118282101715610cc757610cc7610b65565b604052818152828201602001871015610cde575f5ffd5b8160208401602083015e5f602083830101528093505050509250929050565b5f8235605e19833603018112610ba1575f5ffd5b5f60208284031215610d21575f5ffd5b8135610b3581610a1456fea2646970667358221220a157cf3289aec06fad492010b882f74f25341bf2e0bc0fda1acf286411e1150564736f6c634300081d0033" + "code": "0x608060405234801561000f575f5ffd5b50600436106100fb575f3560e01c806372425d9d11610093578063bce38bd711610063578063bce38bd7146101d4578063c3077fa9146101e7578063ea94cddd146101fa578063ee82ac5e14610208575f5ffd5b806372425d9d1461018e57806382ad56cb1461019457806386d516e8146101b4578063a8b0574e146101ba575f5ffd5b8063399542e9116100ce578063399542e9146101455780633e64a6961461016757806342cbb15c1461016d5780634d2301cc14610173575f5ffd5b80630f28c97d146100ff578063252dba421461011457806327e86d6e146101355780633408e4701461013f575b5f5ffd5b425b6040519081526020015b60405180910390f35b61012761012236600461093d565b61021a565b60405161010b9291906109aa565b435f190140610101565b46610101565b610158610153366004610a24565b61038e565b60405161010b93929190610ae8565b48610101565b43610101565b610101610181366004610b0f565b6001600160a01b03163190565b44610101565b6101a76101a236600461093d565b6103ac565b60405161010b9190610b3c565b45610101565b415b6040516001600160a01b03909116815260200161010b565b6101a76101e2366004610a24565b610586565b6101586101f536600461093d565b61072c565b6101bc60036003609b1b0181565b610101610216366004610b4e565b4090565b436060828067ffffffffffffffff81111561023757610237610b65565b60405190808252806020026020018201604052801561026a57816020015b60608152602001906001900390816102555790505b5091505f5b81811015610385575f8060036003609b1b01631595ec0b338a8a8781811061029957610299610b79565b90506020028101906102ab9190610b8d565b6102b9906020810190610b0f565b8b8b888181106102cb576102cb610b79565b90506020028101906102dd9190610b8d565b6102eb906020810190610bab565b6040518563ffffffff1660e01b815260040161030a9493929190610bee565b5f604051808303815f875af1158015610325573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f1916820160405261034c9190810190610c36565b915091508161035d57805160208201fd5b8085848151811061037057610370610b79565b6020908102919091010152505060010161026f565b50509250929050565b5f5f606061039d86868661074a565b91989097509095509350505050565b6060818067ffffffffffffffff8111156103c8576103c8610b65565b60405190808252806020026020018201604052801561040d57816020015b604080518082019091525f8152606060208201528152602001906001900390816103e65790505b5091505f5b8181101561057e575f8060036003609b1b01631595ec0b3389898781811061043c5761043c610b79565b905060200281019061044e9190610cfd565b61045c906020810190610b0f565b8a8a8881811061046e5761046e610b79565b90506020028101906104809190610cfd565b61048e906040810190610bab565b6040518563ffffffff1660e01b81526004016104ad9493929190610bee565b5f604051808303815f875af11580156104c8573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526104ef9190810190610c36565b91509150604051806040016040528083151581526020018281525085848151811061051c5761051c610b79565b602002602001018190525086868481811061053957610539610b79565b905060200281019061054b9190610cfd565b61055c906040810190602001610d11565b158015610567575081155b1561057457805160208201fd5b5050600101610412565b505092915050565b6060818067ffffffffffffffff8111156105a2576105a2610b65565b6040519080825280602002602001820160405280156105e757816020015b604080518082019091525f8152606060208201528152602001906001900390816105c05790505b5091505f5b81811015610723575f8060036003609b1b01631595ec0b3389898781811061061657610616610b79565b90506020028101906106289190610b8d565b610636906020810190610b0f565b8a8a8881811061064857610648610b79565b905060200281019061065a9190610b8d565b610668906020810190610bab565b6040518563ffffffff1660e01b81526004016106879493929190610bee565b5f604051808303815f875af11580156106a2573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526106c99190810190610c36565b915091508780156106d8575081155b156106e557805160208201fd5b604051806040016040528083151581526020018281525085848151811061070e5761070e610b79565b602090810291909101015250506001016105ec565b50509392505050565b5f5f606061073c6001868661074a565b919790965090945092505050565b4380406060838067ffffffffffffffff81111561076957610769610b65565b6040519080825280602002602001820160405280156107ae57816020015b604080518082019091525f8152606060208201528152602001906001900390816107875790505b5091505f5b818110156108ea575f8060036003609b1b01631595ec0b338b8b878181106107dd576107dd610b79565b90506020028101906107ef9190610b8d565b6107fd906020810190610b0f565b8c8c8881811061080f5761080f610b79565b90506020028101906108219190610b8d565b61082f906020810190610bab565b6040518563ffffffff1660e01b815260040161084e9493929190610bee565b5f604051808303815f875af1158015610869573d5f5f3e3d5ffd5b505050506040513d5f823e601f3d908101601f191682016040526108909190810190610c36565b9150915089801561089f575081155b156108ac57805160208201fd5b60405180604001604052808315158152602001828152508584815181106108d5576108d5610b79565b602090810291909101015250506001016107b3565b505093509350939050565b5f5f83601f840112610905575f5ffd5b50813567ffffffffffffffff81111561091c575f5ffd5b6020830191508360208260051b8501011115610936575f5ffd5b9250929050565b5f5f6020838503121561094e575f5ffd5b823567ffffffffffffffff811115610964575f5ffd5b610970858286016108f5565b90969095509350505050565b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b5f604082018483526040602084015280845180835260608501915060608160051b8601019250602086015f5b82811015610a0757605f198786030184526109f285835161097c565b945060209384019391909101906001016109d6565b5092979650505050505050565b8015158114610a21575f5ffd5b50565b5f5f5f60408486031215610a36575f5ffd5b8335610a4181610a14565b9250602084013567ffffffffffffffff811115610a5c575f5ffd5b610a68868287016108f5565b9497909650939450505050565b5f82825180855260208501945060208160051b830101602085015f5b83811015610adc57601f1985840301885281518051151584526020810151905060406020850152610ac5604085018261097c565b6020998a0199909450929092019150600101610a91565b50909695505050505050565b838152826020820152606060408201525f610b066060830184610a75565b95945050505050565b5f60208284031215610b1f575f5ffd5b81356001600160a01b0381168114610b35575f5ffd5b9392505050565b602081525f610b356020830184610a75565b5f60208284031215610b5e575f5ffd5b5035919050565b634e487b7160e01b5f52604160045260245ffd5b634e487b7160e01b5f52603260045260245ffd5b5f8235603e19833603018112610ba1575f5ffd5b9190910192915050565b5f5f8335601e19843603018112610bc0575f5ffd5b83018035915067ffffffffffffffff821115610bda575f5ffd5b602001915036819003821315610936575f5ffd5b6001600160a01b038581168252841660208201526060604082018190528101829052818360808301375f818301608090810191909152601f909201601f191601019392505050565b5f5f60408385031215610c47575f5ffd5b8251610c5281610a14565b602084015190925067ffffffffffffffff811115610c6e575f5ffd5b8301601f81018513610c7e575f5ffd5b805167ffffffffffffffff811115610c9857610c98610b65565b604051601f8201601f19908116603f0116810167ffffffffffffffff81118282101715610cc757610cc7610b65565b604052818152828201602001871015610cde575f5ffd5b8160208401602083015e5f602083830101528093505050509250929050565b5f8235605e19833603018112610ba1575f5ffd5b5f60208284031215610d21575f5ffd5b8135610b3581610a1456fea26469706673582212204bc0d4ad06fa2e58a61c2358062875287957e77a064f68bedb35e66fb6d5adc064736f6c634300081d0033" }, "0x298122B4bF05CC897662e535C18417f44C7f274b": { "balance": "0x0", diff --git a/assets/testnet/genesis.config.ts b/assets/testnet/genesis.config.ts index 468a2c1..5e70228 100644 --- a/assets/testnet/genesis.config.ts +++ b/assets/testnet/genesis.config.ts @@ -68,7 +68,6 @@ const build = async () => { owner: creator.nextAccount('ProtocolConfig.owner', adminPrefund), controller: creator.nextAccount('ProtocolConfig.controller', adminPrefund), pauser: creator.nextAccount('ProtocolConfig.pauser', adminPrefund), - beneficiary: creator.nextAccount('ProtocolConfig.beneficiary'), feeParams: { alpha: 20n, kRate: 25n, diff --git a/contracts/scripts/Addresses.sol b/contracts/scripts/Addresses.sol index 1aeb72d..8a21dc3 100644 --- a/contracts/scripts/Addresses.sol +++ b/contracts/scripts/Addresses.sol @@ -36,8 +36,8 @@ library Addresses { // ============ Predeployed Contracts ============ address internal constant DETERMINISTIC_DEPLOYER_PROXY = 0x4e59b44847b379578588920cA78FbF26c0B4956C; address internal constant MULTICALL3 = 0xcA11bde05977b3631167028862bE2a173976CA11; - address internal constant MULTICALL3_FROM = 0x825F535677d346626cDE45D64cf89C2a426467e0; - address internal constant MEMO = 0xe4aa7Ed3585AEf598179f873086F75Fcd6D4b755; + address internal constant MULTICALL3_FROM = 0xA3E6c63b16321E39a61551Dc1A38689b04d62E42; + address internal constant MEMO = 0x5294E9927c3306DcBaDb03fe70b92e01cCede505; // ============ Helpers ============ diff --git a/contracts/scripts/ProtocolConfigManagement.s.sol b/contracts/scripts/ProtocolConfigManagement.s.sol index 1fe9e93..98ccde4 100644 --- a/contracts/scripts/ProtocolConfigManagement.s.sol +++ b/contracts/scripts/ProtocolConfigManagement.s.sol @@ -32,9 +32,6 @@ import {Addresses} from "./Addresses.sol"; * Print consensus params: * forge script scripts/ProtocolConfigManagement.s.sol --rpc-url --sig "printConsensusParams()" * - * Print reward beneficiary: - * forge script scripts/ProtocolConfigManagement.s.sol --rpc-url --sig "printRewardBeneficiary()" - * * Print all params: * forge script scripts/ProtocolConfigManagement.s.sol --rpc-url --sig "printAllParams()" * @@ -72,10 +69,6 @@ import {Addresses} from "./Addresses.sol"; * * Update all consensus params: * forge script scripts/ProtocolConfigManagement.s.sol --rpc-url --sig "updateAllConsensusParams(uint16,uint16,uint16,uint16,uint16,uint16,uint16,uint16)" --broadcast - * - * ============ Update Reward Beneficiary (controller only, requires CONTROLLER_KEY env var) ============ - * Update reward beneficiary: - * forge script scripts/ProtocolConfigManagement.s.sol --rpc-url --sig "updateRewardBeneficiary(address)" --broadcast */ contract ProtocolConfigManagement is Script { // ============ Constants ============ @@ -110,19 +103,10 @@ contract ProtocolConfigManagement is Script { console.log("targetBlockTimeMs:", params.targetBlockTimeMs); } - function printRewardBeneficiary() public view returns (address beneficiary) { - beneficiary = PROTOCOL_CONFIG.rewardBeneficiary(); - - console.log("ProtocolConfig RewardBeneficiary:"); - console.log("beneficiary:", beneficiary); - } - function printAllParams() public view { printFeeParams(); console.log(""); printConsensusParams(); - console.log(""); - printRewardBeneficiary(); } // ============ Internal Helpers ============ @@ -379,17 +363,6 @@ contract ProtocolConfigManagement is Script { _broadcastConsensusParamsUpdate(params); } - /** - * @notice Updates the reward beneficiary address - * @dev Requires CONTROLLER_KEY env var and controller role on ProtocolConfig - */ - function updateRewardBeneficiary(address newBeneficiary) public { - uint256 controllerKey = _getControllerKey(); - - vm.startBroadcast(controllerKey); - PROTOCOL_CONFIG.updateRewardBeneficiary(newBeneficiary); - vm.stopBroadcast(); - } } /// @title ProtocolConfigState @@ -416,13 +389,7 @@ library ProtocolConfigState { IProtocolConfig.FeeParams memory fee = ProtocolConfig(proxy).feeParams(); IProtocolConfig.ConsensusParams memory cons = ProtocolConfig(proxy).consensusParams(); return keccak256( - abi.encode( - fee, - cons, - ProtocolConfig(proxy).rewardBeneficiary(), - ProtocolConfig(proxy).owner(), - ProtocolConfig(proxy).controller() - ) + abi.encode(fee, cons, ProtocolConfig(proxy).owner(), ProtocolConfig(proxy).controller()) ); } } diff --git a/contracts/src/batch/IMulticall3From.sol b/contracts/src/batch/IMulticall3From.sol index dd17785..78fe930 100644 --- a/contracts/src/batch/IMulticall3From.sol +++ b/contracts/src/batch/IMulticall3From.sol @@ -21,6 +21,14 @@ pragma solidity ^0.8.29; /// contract that routes subcalls through the callFrom precompile. /// Mirrors the original Multicall3 API but without value-forwarding /// methods and without payable modifiers. +/// @dev EOA-only: aggregate-family entrypoints invoke the callFrom precompile, +/// which requires the sender argument (`msg.sender` of Multicall3From) +/// to equal the precompile caller or `tx.origin`. A contract caller is +/// neither, so callFrom reverts and the entire batch reverts regardless +/// of `requireSuccess` / `allowFailure`. +/// +/// Target-contract reverts are captured per-call in the `(success, +/// returnData)` tuple and gated by `requireSuccess` / `allowFailure`. interface IMulticall3From { struct Call { address target; @@ -40,24 +48,29 @@ interface IMulticall3From { /// @notice Aggregates calls, requiring all to succeed. Returns block /// number and an array of return data. + /// @dev EOA-only. See {IMulticall3From}. function aggregate(Call[] calldata calls) external returns (uint256 blockNumber, bytes[] memory returnData); /// @notice Aggregates calls with per-call failure flags. + /// @dev EOA-only. See {IMulticall3From}. function aggregate3(Call3[] calldata calls) external returns (Result[] memory returnData); /// @notice Aggregates calls, requiring all to succeed. Returns block /// number, block hash, and results. + /// @dev EOA-only. See {IMulticall3From}. function blockAndAggregate(Call[] calldata calls) external returns (uint256 blockNumber, bytes32 blockHash, Result[] memory returnData); /// @notice Aggregates calls, with opt-in success requirement. + /// @dev EOA-only. See {IMulticall3From}. function tryAggregate(bool requireSuccess, Call[] calldata calls) external returns (Result[] memory returnData); /// @notice Aggregates calls with opt-in success requirement, returning /// block number, block hash, and results. + /// @dev EOA-only. See {IMulticall3From}. function tryBlockAndAggregate(bool requireSuccess, Call[] calldata calls) external returns (uint256 blockNumber, bytes32 blockHash, Result[] memory returnData); diff --git a/contracts/src/batch/Multicall3From.sol b/contracts/src/batch/Multicall3From.sol index 253c874..16329ef 100644 --- a/contracts/src/batch/Multicall3From.sol +++ b/contracts/src/batch/Multicall3From.sol @@ -28,6 +28,9 @@ import {IMulticall3From} from "./IMulticall3From.sol"; /// callFrom precompile does not support value forwarding on Arc. /// Reentrancy is safe: the contract holds no state that could be /// corrupted by a reentrant call. +/// @dev EOA-only: contract callers hit the callFrom sender-spoofing +/// constraint and the entire batch reverts regardless of +/// `requireSuccess` / `allowFailure`. See {IMulticall3From}. contract Multicall3From is IMulticall3From { ICallFrom public constant CALL_FROM = ICallFrom(Precompiles.CALL_FROM); diff --git a/contracts/src/memo/IMemo.sol b/contracts/src/memo/IMemo.sol index 29cf57b..55333ba 100644 --- a/contracts/src/memo/IMemo.sol +++ b/contracts/src/memo/IMemo.sol @@ -18,6 +18,13 @@ pragma solidity ^0.8.29; /// @title IMemo /// @notice Interface for the Memo contract. +/// @dev EOA-only: {memo} invokes the callFrom precompile, which requires the +/// sender argument (`msg.sender` of Memo) to equal the precompile caller +/// or `tx.origin`. A contract caller is neither, so callFrom reverts and +/// the entire call reverts without raising {MemoFailed}. +/// +/// Target-contract reverts (callFrom returning `success=false`) are +/// re-raised as {MemoFailed} with the target's revert bytes. interface IMemo { /// @notice Thrown when the call via callFrom fails. error MemoFailed(bytes returnData); @@ -39,6 +46,7 @@ interface IMemo { function memoIndex() external view returns (uint256); /// @notice Executes a subcall via the callFrom precompile and emits memo metadata. + /// @dev EOA-only. See {IMemo}. /// @param target The address to call via the precompile. /// @param data The calldata to forward to the target. /// @param memoId A caller-supplied identifier for the memo. diff --git a/contracts/src/memo/Memo.sol b/contracts/src/memo/Memo.sol index e9f6abe..da6b11b 100644 --- a/contracts/src/memo/Memo.sol +++ b/contracts/src/memo/Memo.sol @@ -25,6 +25,8 @@ import {IMemo} from "./IMemo.sol"; * @notice Wraps the callFrom precompile to attach memo metadata to subcalls. * @dev Not upgradeable, no proxy. Deployed at runtime via CREATE2. * The callFrom precompile enforces its own allowlist — this contract does not add access control. + * @dev EOA-only: contract callers hit the callFrom sender-spoofing constraint + * and the call reverts without raising {MemoFailed}. See {IMemo}. */ contract Memo is IMemo { /// @inheritdoc IMemo diff --git a/contracts/src/protocol-config/ProtocolConfig.sol b/contracts/src/protocol-config/ProtocolConfig.sol index 2c2251e..2f4d2ab 100644 --- a/contracts/src/protocol-config/ProtocolConfig.sol +++ b/contracts/src/protocol-config/ProtocolConfig.sol @@ -72,7 +72,8 @@ contract ProtocolConfig is Controller, Pausable, IProtocolConfig { /// @custom:storage-location erc7201:arc.storage.ProtocolConfig struct ProtocolConfigStorage { FeeParams feeParams; - address rewardBeneficiary; + /// @custom:oz-renamed-from rewardBeneficiary + address _deprecatedSlot; ConsensusParams consensusParams; } @@ -125,15 +126,6 @@ contract ProtocolConfig is Controller, Pausable, IProtocolConfig { return $.consensusParams; } - /** - * @notice Returns the current reward beneficiary address - * @return The address of the current reward beneficiary - */ - function rewardBeneficiary() external view override returns (address) { - ProtocolConfigStorage storage $ = _getProtocolConfigStorage(); - return $.rewardBeneficiary; - } - // ============ Mutative Functions ============ /** @@ -174,17 +166,6 @@ contract ProtocolConfig is Controller, Pausable, IProtocolConfig { emit ConsensusParamsUpdated(newParams); } - /** - * @notice Updates the reward beneficiary address - * @dev Only callable by controller when not paused - * @param newBeneficiary The new reward beneficiary address - */ - function updateRewardBeneficiary(address newBeneficiary) external override onlyController whenNotPaused { - ProtocolConfigStorage storage $ = _getProtocolConfigStorage(); - $.rewardBeneficiary = newBeneficiary; - emit RewardBeneficiaryUpdated(newBeneficiary); - } - /** * @notice Updates only the blockGasLimit parameter * @dev Only callable by controller when not paused diff --git a/contracts/src/protocol-config/interfaces/IProtocolConfig.sol b/contracts/src/protocol-config/interfaces/IProtocolConfig.sol index c002c0d..6ef01df 100644 --- a/contracts/src/protocol-config/interfaces/IProtocolConfig.sol +++ b/contracts/src/protocol-config/interfaces/IProtocolConfig.sol @@ -50,9 +50,6 @@ interface IProtocolConfig { /// @dev Emitted each time the controller updates consensus parameters. event ConsensusParamsUpdated(ConsensusParams params); - /// @dev Emitted when the reward beneficiary is reassigned. - event RewardBeneficiaryUpdated(address indexed beneficiary); - /* READ-ONLY API */ /// @notice Returns the latest fee parameters. @@ -61,9 +58,6 @@ interface IProtocolConfig { /// @notice Returns the latest consensus parameters. function consensusParams() external view returns (ConsensusParams memory params); - /// @notice Returns the current reward beneficiary. - function rewardBeneficiary() external view returns (address beneficiary); - /* MUTATIVE API */ /** @@ -82,14 +76,6 @@ interface IProtocolConfig { */ function updateConsensusParams(ConsensusParams calldata newParams) external; - /** - * @notice Change the reward beneficiary address. - * @dev Access – `onlyController` in implementation. - * @dev Access - `whenNotPaused` in implementation. - * @param newBeneficiary The new beneficiary address. - */ - function updateRewardBeneficiary(address newBeneficiary) external; - /** * @notice Update only the blockGasLimit in fee params. * @dev Access – `onlyController` in implementation. diff --git a/contracts/test/protocol-config/ProtocolConfig.t.sol b/contracts/test/protocol-config/ProtocolConfig.t.sol index ca85696..6c01fdf 100644 --- a/contracts/test/protocol-config/ProtocolConfig.t.sol +++ b/contracts/test/protocol-config/ProtocolConfig.t.sol @@ -45,7 +45,6 @@ contract ProtocolConfigTest is Test { event PauserChanged(address indexed newAddress); event FeeParamsUpdated(IProtocolConfig.FeeParams params); event ConsensusParamsUpdated(IProtocolConfig.ConsensusParams params); - event RewardBeneficiaryUpdated(address indexed beneficiary); event Pause(); event Unpause(); @@ -84,11 +83,8 @@ contract ProtocolConfigTest is Test { targetBlockTimeMs: 3000 }); - // Default reward beneficiary - address defaultRewardBeneficiary = makeAddr("defaultRewardBeneficiary"); - return deployProtocolConfig( - _owner, _controller, _pauser, defaultFeeParams, defaultConsensusParams, defaultRewardBeneficiary + _owner, _controller, _pauser, defaultFeeParams, defaultConsensusParams ); } @@ -98,8 +94,7 @@ contract ProtocolConfigTest is Test { address _controller, address _pauser, IProtocolConfig.FeeParams memory _feeParams, - IProtocolConfig.ConsensusParams memory _consensusParams, - address _rewardBeneficiary + IProtocolConfig.ConsensusParams memory _consensusParams ) internal returns (ProtocolConfig) { // Deploy implementation contract implementation = new ProtocolConfig(); @@ -116,7 +111,7 @@ contract ProtocolConfigTest is Test { // Simulate genesis file initialization by directly setting storage using calculated indices _simulateGenesisStorageInitialization( - deployedConfig, _owner, _controller, _pauser, _feeParams, _consensusParams, _rewardBeneficiary + deployedConfig, _owner, _controller, _pauser, _feeParams, _consensusParams ); return deployedConfig; @@ -129,8 +124,7 @@ contract ProtocolConfigTest is Test { address _controller, address _pauser, IProtocolConfig.FeeParams memory _feeParams, - IProtocolConfig.ConsensusParams memory _consensusParams, - address _rewardBeneficiary + IProtocolConfig.ConsensusParams memory _consensusParams ) internal { // This simulates how genesis file would set storage slots directly @@ -164,8 +158,8 @@ contract ProtocolConfigTest is Test { // - minBaseFee (uint256) takes next slot // - maxBaseFee (uint256) takes next slot // - blockGasLimit (uint256) takes next slot + // - deprecated address placeholder (slot +4) takes next slot // Then ConsensusParams struct takes next slots - // Then rewardBeneficiary (address) takes next slot // Slot 0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385200: packed alpha|kRate|inverseElasticityMultiplier bytes32 packedSlot0 = bytes32( @@ -189,8 +183,8 @@ contract ProtocolConfigTest is Test { // timeoutRebroadcastMs (uint16), targetBlockTimeMs (uint16) // pack into one slot (8 * 16 = 128 bits, fits in one slot) - // Slot 0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385204: rewardBeneficiary - vm.store(address(config), bytes32(uint256(baseSlot) + 4), bytes32(uint256(uint160(_rewardBeneficiary)))); + // Slot 0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385204: deprecated address placeholder + // placeholder (slot retained, type preserved as address, no migration). Tests leave at 0x0. // Slot 0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385205: packed consensus params bytes32 packedConsensusSlot = bytes32( @@ -219,8 +213,7 @@ contract ProtocolConfigTest is Test { address _controller, address _pauser, IProtocolConfig.FeeParams memory _feeParams, - IProtocolConfig.ConsensusParams memory _consensusParams, - address _rewardBeneficiary + IProtocolConfig.ConsensusParams memory _consensusParams ) public pure { // Log each storage slot individually to avoid stack too deep @@ -277,11 +270,8 @@ contract ProtocolConfigTest is Test { _toHexStringBytes32(bytes32(_feeParams.blockGasLimit)) ); - console.log("// rewardBeneficiary (base slot + 4)"); - console.log( - '"0x668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385204": "0x%s",', - _toHexStringAddress(_rewardBeneficiary) - ); + // Slot +4 is the deprecated address placeholder — intentionally omitted from + // the example allocation (genesis leaves it zero). console.log("// consensus params (base slot + 5)"); bytes32 packedConsensusValue = bytes32( @@ -307,7 +297,6 @@ contract ProtocolConfigTest is Test { address exampleOwner = makeAddr("exampleOwner"); address exampleController = makeAddr("exampleController"); address examplePauser = makeAddr("examplePauser"); - address exampleRewardBeneficiary = makeAddr("exampleRewardBeneficiary"); IProtocolConfig.FeeParams memory exampleFeeParams = IProtocolConfig.FeeParams({ alpha: 50, @@ -335,8 +324,7 @@ contract ProtocolConfigTest is Test { exampleController, examplePauser, exampleFeeParams, - exampleConsensusParams, - exampleRewardBeneficiary + exampleConsensusParams ); } @@ -394,9 +382,6 @@ contract ProtocolConfigTest is Test { assertEq(consensusParams.timeoutPrecommitDeltaMs, 100); assertEq(consensusParams.timeoutRebroadcastMs, 2000); assertEq(consensusParams.targetBlockTimeMs, 3000); - - // Verify reward beneficiary is set to the default value - assertEq(protocolConfig.rewardBeneficiary(), makeAddr("defaultRewardBeneficiary")); } function test_implementationContractState() public { @@ -417,7 +402,6 @@ contract ProtocolConfigTest is Test { assertEq(params.minBaseFee, 0); assertEq(params.maxBaseFee, 0); assertEq(params.blockGasLimit, 0); - assertEq(impl.rewardBeneficiary(), address(0)); } function test_storageLayoutCalculation() public { @@ -434,8 +418,6 @@ contract ProtocolConfigTest is Test { blockGasLimit: type(uint256).max // Max uint256 }); - address extremeBeneficiary = address(type(uint160).max); // Max address - // Deploy with extreme values IProtocolConfig.ConsensusParams memory extremeConsensusParams = IProtocolConfig.ConsensusParams({ timeoutProposeMs: 2000, @@ -448,7 +430,7 @@ contract ProtocolConfigTest is Test { targetBlockTimeMs: 3000 }); ProtocolConfig extremeConfig = - deployProtocolConfig(owner, controller, pauser, extremeParams, extremeConsensusParams, extremeBeneficiary); + deployProtocolConfig(owner, controller, pauser, extremeParams, extremeConsensusParams); // Verify extreme values are stored and retrieved correctly IProtocolConfig.FeeParams memory retrievedParams = extremeConfig.feeParams(); @@ -458,7 +440,6 @@ contract ProtocolConfigTest is Test { assertEq(retrievedParams.minBaseFee, 1); assertEq(retrievedParams.maxBaseFee, type(uint256).max); assertEq(retrievedParams.blockGasLimit, type(uint256).max); - assertEq(extremeConfig.rewardBeneficiary(), extremeBeneficiary); } function test_directStorageAccess() public { @@ -482,13 +463,14 @@ contract ProtocolConfigTest is Test { bytes32 minBaseFeeSlot = vm.load(address(protocolConfig), bytes32(uint256(baseSlot) + 1)); bytes32 maxBaseFeeSlot = vm.load(address(protocolConfig), bytes32(uint256(baseSlot) + 2)); bytes32 blockGasLimitSlot = vm.load(address(protocolConfig), bytes32(uint256(baseSlot) + 3)); - bytes32 rewardBeneficiarySlot = vm.load(address(protocolConfig), bytes32(uint256(baseSlot) + 4)); + bytes32 reservedSlot4 = vm.load(address(protocolConfig), bytes32(uint256(baseSlot) + 4)); bytes32 consensusParamsSlot = vm.load(address(protocolConfig), bytes32(uint256(baseSlot) + 5)); assertEq(uint256(minBaseFeeSlot), 1000); assertEq(uint256(maxBaseFeeSlot), 2000); assertEq(uint256(blockGasLimitSlot), 30000000); - assertEq(address(uint160(uint256(rewardBeneficiarySlot))), makeAddr("defaultRewardBeneficiary")); + // Deprecated address placeholder — helper leaves it zeroed. + assertEq(uint256(reservedSlot4), 0); // Verify consensus params are packed correctly uint16 storedTimeoutProposeMs = uint16(uint256(consensusParamsSlot)); @@ -982,39 +964,6 @@ contract ProtocolConfigTest is Test { protocolConfig.updateTimeoutProposeMs(123); } - // ============================================================================ - // REWARD BENEFICIARY TESTS - // ============================================================================ - // Tests for updateRewardBeneficiary functionality and validation - - function test_updateRewardBeneficiary__ValidAddress() public { - // Deploy contract - protocolConfig = deployProtocolConfig(owner, controller, pauser); - - address newBeneficiary = makeAddr("newBeneficiary"); - - // Should succeed when called by controller - vm.prank(controller); - vm.expectEmit(true, true, true, true); - emit RewardBeneficiaryUpdated(newBeneficiary); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - - // Verify the beneficiary was updated - assertEq(protocolConfig.rewardBeneficiary(), newBeneficiary); - } - - function test_updateRewardBeneficiary__ZeroAddress() public { - // Deploy contract - protocolConfig = deployProtocolConfig(owner, controller, pauser); - - // Should allow setting beneficiary to zero address - vm.prank(controller); - protocolConfig.updateRewardBeneficiary(address(0)); - - // Verify the beneficiary was updated - assertEq(protocolConfig.rewardBeneficiary(), address(0)); - } - // ============================================================================ // PAUSE FUNCTIONALITY TESTS // ============================================================================ @@ -1104,8 +1053,6 @@ contract ProtocolConfigTest is Test { assertEq(params.maxBaseFee, 2000); assertEq(params.blockGasLimit, 30000000); - assertEq(protocolConfig.rewardBeneficiary(), makeAddr("defaultRewardBeneficiary")); - // Also verify initialized state assertEq(protocolConfig.owner(), owner); assertEq(protocolConfig.controller(), controller); @@ -1199,19 +1146,12 @@ contract ProtocolConfigTest is Test { assertEq(protocolConfig.controller(), controller); assertEq(protocolConfig.pauser(), pauser); assertFalse(protocolConfig.paused()); - assertEq(protocolConfig.rewardBeneficiary(), makeAddr("defaultRewardBeneficiary")); // Set to default value // Verify fee params are set to default values IProtocolConfig.FeeParams memory params = protocolConfig.feeParams(); assertEq(params.alpha, 50); assertEq(params.blockGasLimit, 30000000); - // Test state transitions work correctly - address newBeneficiary = makeAddr("beneficiary"); - vm.prank(controller); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - assertEq(protocolConfig.rewardBeneficiary(), newBeneficiary); - // Test pause state transition vm.prank(pauser); protocolConfig.pause(); @@ -1274,43 +1214,6 @@ contract ProtocolConfigTest is Test { protocolConfig.updateFeeParams(invalidExtremeParams); } - function test_updateRewardBeneficiary_OnlyController() public { - protocolConfig = deployProtocolConfig(owner, controller, pauser); - - address newBeneficiary = makeAddr("newBeneficiary"); - - // Controller should be able to update beneficiary - vm.prank(controller); - vm.expectEmit(true, true, true, true); - emit RewardBeneficiaryUpdated(newBeneficiary); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - assertEq(protocolConfig.rewardBeneficiary(), newBeneficiary); - - // Should fail - unauthorized user - vm.prank(unauthorizedUser); - vm.expectRevert(Controller.CallerIsNotController.selector); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - - // Should fail - owner is not controller - vm.prank(owner); - vm.expectRevert(Controller.CallerIsNotController.selector); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - - // Should fail - pauser is not controller - vm.prank(pauser); - vm.expectRevert(Controller.CallerIsNotController.selector); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - } - - function test_updateRewardBeneficiary_ZeroAddressValidation() public { - protocolConfig = deployProtocolConfig(owner, controller, pauser); - - // Validation logic should work properly - vm.prank(controller); - protocolConfig.updateRewardBeneficiary(address(0)); - assertEq(protocolConfig.rewardBeneficiary(), address(0)); - } - function test_pauseBehaviorAccess() public { protocolConfig = deployProtocolConfig(owner, controller, pauser); @@ -1359,10 +1262,6 @@ contract ProtocolConfigTest is Test { vm.prank(controller); protocolConfig.updateFeeParams(params); - address newBeneficiary = makeAddr("newBeneficiary"); - vm.prank(controller); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - // Now pause the contract vm.prank(pauser); protocolConfig.pause(); @@ -1373,10 +1272,6 @@ contract ProtocolConfigTest is Test { vm.expectRevert(Pausable.ContractPaused.selector); protocolConfig.updateFeeParams(params); - vm.prank(controller); - vm.expectRevert(Pausable.ContractPaused.selector); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - // Unpause and operations should work again vm.prank(pauser); protocolConfig.unpause(); @@ -1449,7 +1344,8 @@ contract ProtocolConfigTest is Test { // The storage struct should have: // - FeeParams feeParams (first field, multiple slots) - // - address rewardBeneficiary (after FeeParams) + // - slot +4: deprecated address placeholder + // - ConsensusParams consensusParams // Test initial values are zero (default) IProtocolConfig.FeeParams memory params = newContract.feeParams(); @@ -1459,6 +1355,5 @@ contract ProtocolConfigTest is Test { assertEq(params.minBaseFee, 0); assertEq(params.maxBaseFee, 0); assertEq(params.blockGasLimit, 0); - assertEq(newContract.rewardBeneficiary(), address(0)); } } diff --git a/contracts/test/protocol-config/ProtocolConfigProxy.t.sol b/contracts/test/protocol-config/ProtocolConfigProxy.t.sol index c1df6f0..59f7a06 100644 --- a/contracts/test/protocol-config/ProtocolConfigProxy.t.sol +++ b/contracts/test/protocol-config/ProtocolConfigProxy.t.sol @@ -43,7 +43,6 @@ contract ProtocolConfigProxyTest is Test { address public controller; // Controller role for ProtocolConfig address public pauser; // Pauser role for ProtocolConfig address public implementationOwner; // Owner of the ProtocolConfig implementation - address public rewardBeneficiary; // Default test parameters IProtocolConfig.FeeParams public defaultFeeParams = IProtocolConfig.FeeParams({ @@ -74,7 +73,6 @@ contract ProtocolConfigProxyTest is Test { controller = makeAddr("controller"); pauser = makeAddr("pauser"); implementationOwner = makeAddr("implementationOwner"); - rewardBeneficiary = makeAddr("rewardBeneficiary"); // Deploy implementation contract implementation = new ProtocolConfig(); @@ -91,7 +89,7 @@ contract ProtocolConfigProxyTest is Test { // Simulate genesis file initialization by directly setting storage _simulateGenesisStorageInitialization( - controller, pauser, defaultFeeParams, defaultConsensusParams, rewardBeneficiary + controller, pauser, defaultFeeParams, defaultConsensusParams ); // Get the actual proxy admin address from ERC1967 storage @@ -107,8 +105,7 @@ contract ProtocolConfigProxyTest is Test { address _controller, address _pauser, IProtocolConfig.FeeParams memory _feeParams, - IProtocolConfig.ConsensusParams memory _consensusParams, - address _rewardBeneficiary + IProtocolConfig.ConsensusParams memory _consensusParams ) internal { // === Set Controller storage === // Controller.controller is in slot 0 (Ownable2StepUpgradeable uses ERC-7201 namespaced storage) @@ -164,12 +161,7 @@ contract ProtocolConfigProxyTest is Test { bytes32(uint256(_feeParams.blockGasLimit)) ); - // Slot 4: rewardBeneficiary (address) - vm.store( - address(protocolConfig), - bytes32(uint256(PROTOCOL_CONFIG_STORAGE_LOCATION) + 4), - bytes32(uint256(uint160(_rewardBeneficiary))) - ); + // Slot 4: deprecated address placeholder. Left zeroed. // ConsensusParams struct layout: // - timeoutProposeMs (uint16), timeoutProposeDeltaMs (uint16), timeoutPrevoteMs (uint16), @@ -212,8 +204,6 @@ contract ProtocolConfigProxyTest is Test { assertEq(consensusParams.timeoutPrecommitDeltaMs, defaultConsensusParams.timeoutPrecommitDeltaMs); assertEq(consensusParams.timeoutRebroadcastMs, defaultConsensusParams.timeoutRebroadcastMs); assertEq(consensusParams.targetBlockTimeMs, defaultConsensusParams.targetBlockTimeMs); - - assertEq(protocolConfig.rewardBeneficiary(), rewardBeneficiary); } function test_UpdateFeeParamsViaProxy() public { @@ -266,15 +256,6 @@ contract ProtocolConfigProxyTest is Test { assertEq(updatedParams.targetBlockTimeMs, 6000); } - function test_UpdateRewardBeneficiaryViaProxy() public { - address newBeneficiary = makeAddr("newBeneficiary"); - - vm.prank(controller); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - - assertEq(protocolConfig.rewardBeneficiary(), newBeneficiary); - } - function test_AccessControlViaProxy() public { // Test that access control works through the proxy IProtocolConfig.FeeParams memory newParams = IProtocolConfig.FeeParams({ @@ -348,14 +329,9 @@ contract ProtocolConfigProxyTest is Test { vm.prank(controller); protocolConfig.updateFeeParams(modifiedParams); - address newBeneficiary = makeAddr("upgradeBeneficiary"); - vm.prank(controller); - protocolConfig.updateRewardBeneficiary(newBeneficiary); - // Verify state before upgrade IProtocolConfig.FeeParams memory paramsBeforeUpgrade = protocolConfig.feeParams(); assertEq(paramsBeforeUpgrade.alpha, 77); - assertEq(protocolConfig.rewardBeneficiary(), newBeneficiary); // Deploy new implementation and upgrade ProtocolConfig newImplementation = new ProtocolConfig(); @@ -370,6 +346,5 @@ contract ProtocolConfigProxyTest is Test { assertEq(paramsAfterUpgrade.minBaseFee, 1111); assertEq(paramsAfterUpgrade.maxBaseFee, 2222); assertEq(paramsAfterUpgrade.blockGasLimit, 33333333); - assertEq(protocolConfig.rewardBeneficiary(), newBeneficiary); } } diff --git a/contracts/test/scripts/ProtocolConfigManagement.t.sol b/contracts/test/scripts/ProtocolConfigManagement.t.sol index cdea16f..ffdb44c 100644 --- a/contracts/test/scripts/ProtocolConfigManagement.t.sol +++ b/contracts/test/scripts/ProtocolConfigManagement.t.sol @@ -124,25 +124,4 @@ contract ProtocolConfigManagementTest is Test { assertEq(_feeParams.alpha, 0); assertEq(_feeParams.kRate, 0); } - - function test_UpdateRewardBeneficiary_Succeeds() public { - vm.setEnv("CONTROLLER_KEY", vm.toString(controllerPk)); - - address newBeneficiary = makeAddr("newBeneficiary"); - script.updateRewardBeneficiary(newBeneficiary); - - assertEq(protocolConfig.rewardBeneficiary(), newBeneficiary); - } - - function test_UpdateRewardBeneficiary_AcceptsZeroAddress() public { - vm.setEnv("CONTROLLER_KEY", vm.toString(controllerPk)); - - // Seed a non-zero beneficiary first so we can observe the clear. - address seedBeneficiary = makeAddr("seedBeneficiary"); - script.updateRewardBeneficiary(seedBeneficiary); - assertEq(protocolConfig.rewardBeneficiary(), seedBeneficiary); - - script.updateRewardBeneficiary(address(0)); - assertEq(protocolConfig.rewardBeneficiary(), address(0)); - } } diff --git a/crates/consensus-db/src/lib.rs b/crates/consensus-db/src/lib.rs index a451c9b..bb12a61 100644 --- a/crates/consensus-db/src/lib.rs +++ b/crates/consensus-db/src/lib.rs @@ -27,8 +27,9 @@ pub mod versions; mod store; pub use store::{ - DbUpgrade, Store, StoreError, CERTIFICATES_TABLE, DECIDED_BLOCKS_TABLE, INVALID_PAYLOADS_TABLE, - MISBEHAVIOR_EVIDENCE_TABLE, PENDING_PROPOSAL_PARTS_TABLE, PROPOSAL_MONITOR_DATA_TABLE, + 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, }; diff --git a/crates/consensus-db/src/store.rs b/crates/consensus-db/src/store.rs index 5dc4c43..cafb67a 100644 --- a/crates/consensus-db/src/store.rs +++ b/crates/consensus-db/src/store.rs @@ -99,6 +99,140 @@ impl StoreError { } } +/// Per-table deletion counts from a rollback operation. +#[derive(Debug, Default)] +pub struct RollbackReport { + pub certificates: usize, + pub decided_blocks: usize, + pub invalid_payloads: usize, + pub misbehavior_evidence: usize, + pub proposal_monitor_data: usize, + pub undecided_blocks: usize, + pub pending_proposal_parts: usize, +} + +/// Default number of heights deleted per write transaction during rollback. +pub const ROLLBACK_BATCH_SIZE: u64 = 1000; + +/// Roll back all consensus tables to `target_height`, removing entries above it. +/// +/// When `dry_run` is true, entries are counted but the transaction is aborted +/// so the database remains unchanged. +/// +/// Deletions are batched in chunks of `batch_size` heights. Each batch is a +/// single write transaction across all 7 tables, so a crash mid-rollback leaves +/// the database consistent at some intermediate height. +/// +/// Operates directly on a `redb::Database` so CLI commands can call it without +/// constructing a full `Store`. +pub fn rollback_to_height( + db: &redb::Database, + target_height: Height, + batch_size: u64, + dry_run: bool, +) -> Result { + assert!(batch_size > 0, "batch_size must be > 0"); + + let mut report = RollbackReport::default(); + + let current_height = { + let tx = db.begin_read()?; + let table = tx.open_table(CERTIFICATES_TABLE)?; + match table.last()? { + Some((k, _)) => k.value(), + None => return Ok(report), + } + }; + + if current_height <= target_height { + return Ok(report); + } + + // Work from the tip downward so a crash leaves a contiguous prefix, not a gap. + + // Redb retain_in expects a range exclusive of the upper bound, so we increment + let stop = target_height.increment(); + let mut range_high = current_height.increment(); + loop { + if range_high == stop { + break; + } + let range_low = range_high.saturating_sub(batch_size).max(stop); + + // Round::Nil serializes to -1 and BlockHash::ZERO is all zeros, so these + // are the minimum (round, hash) values — the composite range captures all + // entries within the height range regardless of round or block hash. + let composite_start = (range_low, Round::Nil, BlockHash::ZERO); + let composite_end = (range_high, Round::Nil, BlockHash::ZERO); + + info!( + batch_start = %range_low, + batch_end = %range_high, + "Rolling back height batch" + ); + + let tx = db.begin_write()?; + { + let mut t = tx.open_table(CERTIFICATES_TABLE)?; + t.retain_in(range_low..range_high, |_, _| { + report.certificates = report.certificates.saturating_add(1); + dry_run + })?; + + let mut t = tx.open_table(DECIDED_BLOCKS_TABLE)?; + t.retain_in(range_low..range_high, |_, _| { + report.decided_blocks = report.decided_blocks.saturating_add(1); + dry_run + })?; + + let mut t = tx.open_table(INVALID_PAYLOADS_TABLE)?; + t.retain_in(range_low..range_high, |_, _| { + report.invalid_payloads = report.invalid_payloads.saturating_add(1); + dry_run + })?; + + let mut t = tx.open_table(MISBEHAVIOR_EVIDENCE_TABLE)?; + t.retain_in(range_low..range_high, |_, _| { + report.misbehavior_evidence = report.misbehavior_evidence.saturating_add(1); + dry_run + })?; + + let mut t = tx.open_table(PROPOSAL_MONITOR_DATA_TABLE)?; + t.retain_in(range_low..range_high, |_, _| { + report.proposal_monitor_data = report.proposal_monitor_data.saturating_add(1); + dry_run + })?; + + let mut t = tx.open_table(UNDECIDED_BLOCKS_TABLE)?; + t.retain_in(composite_start..composite_end, |_, _| { + report.undecided_blocks = report.undecided_blocks.saturating_add(1); + dry_run + })?; + + let mut t = tx.open_table(PENDING_PROPOSAL_PARTS_TABLE)?; + t.retain_in(composite_start..composite_end, |_, _| { + report.pending_proposal_parts = report.pending_proposal_parts.saturating_add(1); + dry_run + })?; + } + + if dry_run { + tx.abort()?; + } else { + tx.commit()?; + } + + info!( + updated_head = range_low.as_u64().saturating_sub(1), + dry_run = dry_run, + "Batch committed" + ); + range_high = range_low; + } + + Ok(report) +} + pub const CERTIFICATES_TABLE: redb::TableDefinition> = redb::TableDefinition::new("certificates"); diff --git a/crates/evm-node/src/node.rs b/crates/evm-node/src/node.rs index 6c10079..fd2ff96 100644 --- a/crates/evm-node/src/node.rs +++ b/crates/evm-node/src/node.rs @@ -97,8 +97,11 @@ pub struct ArcNode { /// When true, `on_missing_payload` waits for the in-flight build instead of /// racing an empty block. pub wait_for_payload: bool, - /// When true (default), pending-tx RPCs are restricted (`eth_subscribe("newPendingTransactions")`, `eth_newPendingTransactionFilter`). - /// When false, all requests are forwarded. + /// When true (default), pending-tx RPCs are restricted + /// (`eth_subscribe("newPendingTransactions")`, `eth_newPendingTransactionFilter`, + /// and `eth_getBlockByNumber("pending")`). When false, all requests are forwarded. + /// CLI users opt out of the default via `--arc.expose-pending-txs`. + /// `--public-api` also forces this to `true` (and conflicts with `--arc.expose-pending-txs`). pub filter_pending_txs: bool, /// Interval between tx rebroadcast rounds. Zero disables rebroadcast. pub rebroadcast_interval: std::time::Duration, diff --git a/crates/evm-node/src/rpc_middleware.rs b/crates/evm-node/src/rpc_middleware.rs index aabdebc..3c8a648 100644 --- a/crates/evm-node/src/rpc_middleware.rs +++ b/crates/evm-node/src/rpc_middleware.rs @@ -32,15 +32,25 @@ const PENDING_BLOCK_TAG: &str = "pending"; const PENDING_TX_SUBSCRIPTION_ERROR_CODE: i32 = -32001; /// Adds Arc-specific RPC middlewares -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct ArcRpcLayer { - /// When true, `eth_subscribe("newPendingTransactions")`, + /// When true (default), `eth_subscribe("newPendingTransactions")`, /// `eth_newPendingTransactionFilter`, and `eth_getBlockByNumber("pending")` - /// are blocked. When false (default), the filter is bypassed and these are - /// allowed. Opt-in via `--arc.hide-pending-txs`. + /// are blocked. 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, } +impl Default for ArcRpcLayer { + /// Defaults to the secure configuration: pending-tx RPCs blocked. + /// CLI callers opt out via `--arc.expose-pending-txs`. + fn default() -> Self { + Self { + filter_pending_txs: true, + } + } +} + impl ArcRpcLayer { /// Creates a new `ArcRpcLayer` with the given filter setting. pub fn new(filter_pending_txs: bool) -> Self { @@ -66,7 +76,7 @@ where } /// RPC middleware that prevents websocket subscriptions and HTTP filters for pending transactions. -#[derive(Clone, Default, Debug)] +#[derive(Clone, Debug)] pub struct NoPendingTransactionsRpcMiddleware { service: S, } @@ -278,12 +288,12 @@ mod tests { Request::owned(method.to_string(), Some(params), Id::Number(id)) } - // ── Middleware active (--arc.hide-pending-txs) ────────────────────── + // ── Middleware active (default) ───────────────────────────────────── // // When active, the middleware intercepts pending-state RPCs: // subscriptions, filters, and block queries. - // The binary default is --arc.hide-pending-txs=false (middleware OFF). - // Set --arc.hide-pending-txs on hardened nodes to activate. + // The binary default is middleware ON; users opt out via + // --arc.expose-pending-txs on trusted/internal nodes. // -- pending txs: blocked -- @@ -528,7 +538,7 @@ mod tests { assert_eq!(responses[1]["result"], "success"); } - // ── Middleware disabled (default, --arc.hide-pending-txs not set) ──── + // ── Middleware disabled (--arc.expose-pending-txs) ────────────────── // // The middleware is bypassed entirely. All requests pass through. @@ -582,11 +592,11 @@ mod tests { // ── ArcRpcLayer::default() ────────────────────────────────────────── #[test] - fn test_arc_rpc_layer_default_has_filter_disabled() { + fn test_arc_rpc_layer_default_has_filter_enabled() { let layer = ArcRpcLayer::default(); assert!( - !layer.filter_pending_txs, - "Default ArcRpcLayer should have filter disabled (opt-in via --arc.hide-pending-txs)" + layer.filter_pending_txs, + "Default ArcRpcLayer should have filter enabled (opt out via --arc.expose-pending-txs)" ); } } diff --git a/crates/evm/src/executor.rs b/crates/evm/src/executor.rs index 99181ea..82359ac 100644 --- a/crates/evm/src/executor.rs +++ b/crates/evm/src/executor.rs @@ -497,6 +497,9 @@ where 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, diff --git a/crates/execution-config/src/call_from.rs b/crates/execution-config/src/call_from.rs index 853c8c9..c2d33d9 100644 --- a/crates/execution-config/src/call_from.rs +++ b/crates/execution-config/src/call_from.rs @@ -21,7 +21,7 @@ use alloy_primitives::{address, Address}; /// Address of the `Memo` contract (CREATE2-deployed, zero salt). -pub const MEMO_ADDRESS: Address = address!("e4aa7Ed3585AEf598179f873086F75Fcd6D4b755"); +pub const MEMO_ADDRESS: Address = address!("5294E9927c3306DcBaDb03fe70b92e01cCede505"); /// Address of the `Multicall3From` contract (CREATE2-deployed, zero salt). -pub const MULTICALL3_FROM_ADDRESS: Address = address!("825F535677d346626cDE45D64cf89C2a426467e0"); +pub const MULTICALL3_FROM_ADDRESS: Address = address!("A3E6c63b16321E39a61551Dc1A38689b04d62E42"); diff --git a/crates/execution-config/src/chainspec.rs b/crates/execution-config/src/chainspec.rs index 123a0d3..15a38c7 100644 --- a/crates/execution-config/src/chainspec.rs +++ b/crates/execution-config/src/chainspec.rs @@ -326,9 +326,6 @@ impl BaseFeeConfigProvider for ArcChainSpec { #[cfg(any(feature = "test-utils", test))] const PROTOCOL_CONFIG_BLOCK_GAS_LIMIT_SLOT: B256 = b256!("668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385203"); -#[cfg(any(feature = "test-utils", test))] -const PROTOCOL_CONFIG_REWARD_BENEFICIARY_SLOT: B256 = - b256!("668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385204"); /// ERC-1967 implementation slot on the proxy. #[cfg(any(feature = "test-utils", test))] const PROXY_IMPLEMENTATION_SLOT: B256 = @@ -393,28 +390,13 @@ pub fn localdev_with_hardforks(hardforks: &[(ArcHardfork, u64)]) -> Arc, -) -> Arc { +pub fn localdev_with_storage_override(blocklisted_address: Option
) -> Arc { let mut genesis: Genesis = serde_json::from_str(include_str!("../../../assets/localdev/genesis.json")) .expect("Can't deserialize localdev genesis json"); - let protocol_config_account = genesis - .alloc - .get_mut(&crate::protocol_config::PROTOCOL_CONFIG_ADDRESS) - .expect("LOCAL_DEV genesis missing ProtocolConfig account"); - - let storage = protocol_config_account - .storage - .get_or_insert_with(Default::default); - storage.insert( - PROTOCOL_CONFIG_REWARD_BENEFICIARY_SLOT, - beneficiary.into_word(), - ); if let Some(blocklisted_address) = blocklisted_address { const BLOCKLISTED_STATUS: B256 = b256!("0000000000000000000000000000000000000000000000000000000000000001"); diff --git a/crates/execution-config/src/native_coin_control.rs b/crates/execution-config/src/native_coin_control.rs index 2fdd71d..dd9f891 100644 --- a/crates/execution-config/src/native_coin_control.rs +++ b/crates/execution-config/src/native_coin_control.rs @@ -42,7 +42,7 @@ pub fn compute_is_blocklisted_storage_slot(key: Address) -> B256 { data[..32].copy_from_slice(&key_bytes); data[32..].copy_from_slice(BLOCKLIST_MAPPING_SLOT.as_ref()); - B256::new(keccak256(data).0) + keccak256(data) } /// Returns true if a blocklist storage word means "blocked". diff --git a/crates/execution-e2e/CLAUDE.md b/crates/execution-e2e/CLAUDE.md new file mode 100644 index 0000000..295614b --- /dev/null +++ b/crates/execution-e2e/CLAUDE.md @@ -0,0 +1,42 @@ +# Execution E2E Tests + +Rust e2e tests for the execution layer using `ArcTestBuilder` with mock consensus (`reth-engine-local`). Tests run a real Reth node without requiring a network. + +## Quick Reference + +Test pattern: + +```rust +ArcTestBuilder::new() + .with_setup(ArcSetup::new()) // configure node + wallet + .with_action(...) // add sequential actions + .run().await // execute all actions +``` + +Hardfork testing: use `localdev_with_hardforks()` for custom activation blocks. +Default `ArcSetup::new()` activates ALL hardforks at block 0. + +## Running Tests + +``` +cargo nextest run --locked -p arc-execution-e2e +``` + +This crate has an `integration` feature for tests requiring additional infrastructure, but standard e2e tests run without it. + +## Full Guidance + +See `.claude/skills/arc-rust-e2e-tests/SKILL.md` for: +- Complete action catalog (10 actions) +- Hardfork gating patterns (4 patterns for Zero3–Zero6) +- Chainspec helpers +- Worked examples + +## Key Files + +- `src/lib.rs` — ArcTestBuilder, re-exports +- `src/action.rs` — Action trait +- `src/setup.rs` — ArcSetup (node initialization) +- `src/chainspec.rs` — chainspec helper re-exports +- `src/actions/` — all available actions +- `tests/` — test files (13 scenarios) diff --git a/crates/execution-e2e/tests/beneficiary_blocklist.rs b/crates/execution-e2e/tests/beneficiary_blocklist.rs index 673acaa..b9c2c09 100644 --- a/crates/execution-e2e/tests/beneficiary_blocklist.rs +++ b/crates/execution-e2e/tests/beneficiary_blocklist.rs @@ -16,7 +16,7 @@ //! E2E test covering beneficiary blocklist enforcement during payload validation. -use alloy_primitives::{address, Address}; +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}, @@ -34,7 +34,7 @@ async fn test_proposer_selected_blocklisted_beneficiary_is_invalid() -> Result<( reth_tracing::init_test_tracing(); let blocklisted_beneficiary = address!("0xbad0000000000000000000000000000000000001"); - let chain_spec = localdev_with_storage_override(Address::ZERO, Some(blocklisted_beneficiary)); + let chain_spec = localdev_with_storage_override(Some(blocklisted_beneficiary)); let mut env = ArcEnvironment::new(); ArcSetup::new() diff --git a/crates/execution-txpool/Cargo.toml b/crates/execution-txpool/Cargo.toml index e44e076..5f4dfb3 100644 --- a/crates/execution-txpool/Cargo.toml +++ b/crates/execution-txpool/Cargo.toml @@ -37,6 +37,8 @@ tracing.workspace = true [dev-dependencies] alloy-consensus.workspace = true +alloy-eips.workspace = true +k256 = { workspace = true, features = ["ecdsa"] } reth-evm-ethereum = { workspace = true, default-features = false, features = ["std"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-transaction-pool = { workspace = true, features = ["test-utils"] } diff --git a/crates/execution-txpool/src/validator.rs b/crates/execution-txpool/src/validator.rs index 80fd70a..931b444 100644 --- a/crates/execution-txpool/src/validator.rs +++ b/crates/execution-txpool/src/validator.rs @@ -307,11 +307,21 @@ where return Ok(None); } - let addresses = std::iter::once(transaction.sender()).chain(transaction.to()); + let auth_authorities: Vec
= transaction + .authorization_list() + .into_iter() + .flatten() + .filter_map(|auth| auth.recover_authority().ok()) + .collect(); + + let addresses = std::iter::once(transaction.sender()) + .chain(transaction.to()) + .chain(auth_authorities); + for address in addresses { match is_denylisted(state_provider, &self.addresses_denylist_config, address) { Ok(true) => return Ok(Some(address)), - Ok(false) => {} // continue to next address + Ok(false) => {} Err(DenylistError::StorageReadFailed(e)) => return Err(e), } } @@ -327,6 +337,7 @@ where state_provider: &dyn StateProvider, ) -> ProviderResult> { let has_value = !transaction.value().is_zero(); + let addresses = std::iter::once(transaction.sender()).chain(transaction.to().filter(|_| has_value)); @@ -728,6 +739,191 @@ mod tests { } } + /// Creates a signed EIP-7702 authorization with a fresh key. + /// Returns `(authority_address, SignedAuthorization)`. + fn create_signed_authorization( + chain_id: u64, + delegate_address: Address, + nonce: u64, + ) -> (Address, alloy_eips::eip7702::SignedAuthorization) { + use alloy_eips::eip7702::Authorization; + use alloy_primitives::Signature; + use k256::ecdsa::SigningKey; + + let key_byte = delegate_address.0[0] | 1; // deterministic, non-zero + let key_bytes: [u8; 32] = [key_byte; 32]; + let signing_key = SigningKey::from_bytes((&key_bytes).into()).expect("fixed key is valid"); + let auth = Authorization { + chain_id: U256::from(chain_id), + address: delegate_address, + nonce, + }; + let sig_hash = auth.signature_hash(); + let (sig, recid) = signing_key + .sign_prehash_recoverable(sig_hash.as_ref()) + .expect("signing must succeed"); + let alloy_sig = Signature::new( + U256::from_be_slice(&sig.r().to_bytes()), + U256::from_be_slice(&sig.s().to_bytes()), + recid.is_y_odd(), + ); + let signed_auth = auth.into_signed(alloy_sig); + let authority = signed_auth + .recover_authority() + .expect("authority recovery must succeed"); + (authority, signed_auth) + } + + const DENYLIST_CONTRACT: Address = Address::new([0x36u8; 20]); + + fn eip7702_tx_with_auths( + auth_list: Vec, + ) -> MockTransaction { + // 100_000 gas covers EIP-7702 intrinsic (21_000 + 12_500 per auth) + let mut tx = MockTransaction::eip7702() + .with_gas_limit(100_000) + .with_gas_price(1_000_000_000); + tx.set_authorization_list(auth_list); + tx + } + + fn provider_with_funded_accounts(tx: &MockTransaction) -> MockEthProvider { + let provider = MockEthProvider::default(); + // Post-Prague timestamp so the inner validator accepts EIP-7702 transactions + let mut block = reth_ethereum_primitives::Block::default(); + block.header.timestamp = 2_000_000_000; + provider.add_block(B256::ZERO, block); + provider.add_account(tx.sender(), ExtendedAccount::new(0, U256::MAX)); + provider.add_account(tx.to().unwrap(), ExtendedAccount::new(0, U256::MAX)); + provider + } + + fn add_denylisted_address(provider: &MockEthProvider, address: Address) { + let slot = compute_denylist_storage_slot(address, DEFAULT_DENYLIST_ERC7201_BASE_SLOT); + let mut storage = std::collections::HashMap::new(); + storage.insert(slot, U256::from(1)); + provider.add_account( + DENYLIST_CONTRACT, + ExtendedAccount::new(0, U256::ZERO).extend_storage(storage), + ); + } + + fn denylist_config(exclusions: Vec
) -> AddressesDenylistConfig { + AddressesDenylistConfig::try_new( + true, + Some(DENYLIST_CONTRACT), + Some(DEFAULT_DENYLIST_ERC7201_BASE_SLOT), + exclusions, + ) + .unwrap() + } + + async fn validate_eip7702( + tx: MockTransaction, + provider: MockEthProvider, + config: AddressesDenylistConfig, + ) -> TransactionValidationOutcome { + let blob_store = InMemoryBlobStore::default(); + let eth_validator = EthTransactionValidatorBuilder::new(provider, EthEvmConfig::mainnet()) + .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, + ) { + let TransactionValidationOutcome::Invalid(_, err) = outcome else { + panic!("expected Invalid outcome, got {outcome:?}"); + }; + let inner: &ArcTransactionValidatorError = err + .downcast_other_ref::() + .unwrap(); + assert!( + matches!(inner, ArcTransactionValidatorError::DenylistedAddressError(addr) if *addr == expected_addr), + "expected DenylistedAddressError({expected_addr}), got {inner:?}" + ); + } + + fn assert_valid(outcome: &TransactionValidationOutcome) { + assert!( + matches!(outcome, TransactionValidationOutcome::Valid { .. }), + "expected Valid outcome, got {outcome:?}" + ); + } + + #[tokio::test] + async fn eip7702_denylisted_authority_rejected() { + let (authority, signed_auth) = create_signed_authorization(1, Address::from([0xAA; 20]), 0); + let tx = eip7702_tx_with_auths(vec![signed_auth]); + let provider = provider_with_funded_accounts(&tx); + add_denylisted_address(&provider, authority); + + let outcome = validate_eip7702(tx, provider, denylist_config(Vec::new())).await; + assert_denylist_rejected(&outcome, authority); + } + + #[tokio::test] + async fn eip7702_multiple_auths_second_denylisted_rejected() { + let (_clean, clean_auth) = create_signed_authorization(1, Address::from([0xAA; 20]), 0); + let (denylisted, denylisted_auth) = + create_signed_authorization(1, Address::from([0xBB; 20]), 0); + let tx = eip7702_tx_with_auths(vec![clean_auth, denylisted_auth]); + let provider = provider_with_funded_accounts(&tx); + add_denylisted_address(&provider, denylisted); + + let outcome = validate_eip7702(tx, provider, denylist_config(Vec::new())).await; + assert_denylist_rejected(&outcome, denylisted); + } + + #[tokio::test] + async fn eip7702_denylisted_authority_excluded_passes() { + let (authority, signed_auth) = create_signed_authorization(1, Address::from([0xEE; 20]), 0); + let tx = eip7702_tx_with_auths(vec![signed_auth]); + let provider = provider_with_funded_accounts(&tx); + add_denylisted_address(&provider, authority); + + let outcome = validate_eip7702(tx, provider, denylist_config(vec![authority])).await; + assert_valid(&outcome); + } + + #[tokio::test] + async fn eip7702_non_denylisted_authority_passes_denylist_check() { + let (_authority, signed_auth) = + create_signed_authorization(1, Address::from([0xBB; 20]), 0); + let tx = eip7702_tx_with_auths(vec![signed_auth]); + let provider = provider_with_funded_accounts(&tx); + provider.add_account(DENYLIST_CONTRACT, ExtendedAccount::new(0, U256::ZERO)); + + 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 + let invalid_auth = alloy_eips::eip7702::SignedAuthorization::new_unchecked( + alloy_eips::eip7702::Authorization { + chain_id: U256::from(1), + address: Address::from([0xCC; 20]), + nonce: 0, + }, + 2, + U256::from(1), + U256::from(1), + ); + let tx = eip7702_tx_with_auths(vec![invalid_auth]); + let provider = provider_with_funded_accounts(&tx); + provider.add_account(DENYLIST_CONTRACT, ExtendedAccount::new(0, U256::ZERO)); + + let outcome = validate_eip7702(tx, provider, denylist_config(Vec::new())).await; + assert_valid(&outcome); + } + static HITS: AtomicU64 = AtomicU64::new(0); static SIZE_LAST: AtomicU64 = AtomicU64::new(0); static INSERTS: AtomicU64 = AtomicU64::new(0); diff --git a/crates/execution-validation/src/consensus.rs b/crates/execution-validation/src/consensus.rs index fc0f618..3719140 100644 --- a/crates/execution-validation/src/consensus.rs +++ b/crates/execution-validation/src/consensus.rs @@ -1048,4 +1048,26 @@ mod tests { "Non-zero beneficiary should pass: {result:?}" ); } + + #[test] + fn test_beneficiary_nonzero_skipped_before_zero6() { + 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()); + assert!( + result.is_ok(), + "Before Zero6, zero beneficiary should be allowed: {result:?}" + ); + } } diff --git a/crates/malachite-app/src/handlers/finalized.rs b/crates/malachite-app/src/handlers/finalized.rs index 169e157..61dd2ba 100644 --- a/crates/malachite-app/src/handlers/finalized.rs +++ b/crates/malachite-app/src/handlers/finalized.rs @@ -57,15 +57,29 @@ pub async fn handle( StoredMisbehaviorEvidence::from_misbehavior_evidence(certificate.height, &evidence); let validators_count = stored_evidence.validators.len(); + let validator_addresses = stored_evidence + .validators + .iter() + .map(|v| v.address.to_string()) + .collect::>() + .join(", "); if let Err(e) = state .store() .store_misbehavior_evidence(stored_evidence) .await { - warn!("Failed to store misbehavior evidence: {e:#}"); + error!( + %validators_count, + %validator_addresses, + "Failed to store misbehavior evidence: {e:#}" + ); } else { - info!(%validators_count, "Stored misbehavior evidence"); + warn!( + %validators_count, + %validator_addresses, + "Stored misbehavior evidence" + ); } } diff --git a/crates/malachite-app/src/handlers/started_round.rs b/crates/malachite-app/src/handlers/started_round.rs index 9039267..f15905e 100644 --- a/crates/malachite-app/src/handlers/started_round.rs +++ b/crates/malachite-app/src/handlers/started_round.rs @@ -185,14 +185,14 @@ async fn process_pending_proposal_parts( } // NOTE: The block is initially assigned a default validity status - // (i.e., `Validity::VALID`), even though it has not yet been validated + // (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 right after this - // call, in `AppMsg::StartedRound` (see `app.rs`). + // 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"); @@ -226,6 +226,9 @@ async fn process_pending_proposal_parts( /// 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. +/// +/// After each block is validated, the engine's verdict is persisted back to the +/// undecided blocks table. async fn validate_undecided_blocks( height: Height, round: Round, @@ -260,9 +263,19 @@ async fn validate_undecided_blocks( } }; - // Update the block validity 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); if !validity.is_valid() { @@ -306,6 +319,8 @@ mod tests { MockInvalidPayloadsRepository, MockUndecidedBlocksRepository, }; + use std::sync::{Arc, Mutex}; + use alloy_rpc_types_engine::ExecutionPayloadV3; use arbitrary::{Arbitrary, Unstructured}; use malachitebft_core_types::Validity; @@ -338,6 +353,11 @@ 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(())); let mut validator = MockPayloadValidator::new(); validator @@ -370,6 +390,17 @@ mod tests { .expect_get_by_round() .returning(move |_, _| Ok(blocks.clone())); + // Record the validity of every block persisted, in call 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(()) + }); + let mut call_count = 0usize; let mut validator = MockPayloadValidator::new(); validator @@ -396,6 +427,11 @@ mod tests { 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" + ); } #[tokio::test] @@ -452,6 +488,13 @@ mod tests { undecided .expect_get_by_round() .returning(move |_, _| Ok(blocks.clone())); + // Only the block that successfully validated should be persisted, the + // errored block must be skipped entirely. + undecided + .expect_store_undecided_block() + .times(1) + .withf(|b| b.validity == Validity::Valid) + .returning(|_| Ok(())); let mut call_count = 0usize; let mut validator = MockPayloadValidator::new(); @@ -478,4 +521,40 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].validity, Validity::Valid); } + + #[tokio::test] + async fn validate_undecided_blocks_propagates_persist_error() { + let height = Height::new(1); + let round = Round::new(0); + + let block = create_dummy_block(height, round, 0x33); + let blocks = vec![block]; + + let mut undecided = MockUndecidedBlocksRepository::new(); + undecided + .expect_get_by_round() + .returning(move |_, _| Ok(blocks.clone())); + undecided + .expect_store_undecided_block() + .times(1) + .returning(|_| Err(std::io::Error::other("disk full"))); + + let mut validator = MockPayloadValidator::new(); + validator + .expect_validate_payload() + .times(1) + .returning(|_| Ok(PayloadValidationResult::Valid)); + + let invalid = MockInvalidPayloadsRepository::new(); + + let err = validate_undecided_blocks(height, round, &undecided, &validator, &invalid) + .await + .expect_err("persist error should propagate"); + + assert!( + err.to_string() + .contains("Failed to persist validated undecided block"), + "error should describe the persist failure, got: {err}", + ); + } } diff --git a/crates/malachite-app/src/lib.rs b/crates/malachite-app/src/lib.rs index e4a3068..6cb900e 100644 --- a/crates/malachite-app/src/lib.rs +++ b/crates/malachite-app/src/lib.rs @@ -39,5 +39,4 @@ pub mod node; pub mod request; pub mod rpc; pub mod rpc_sync; -pub mod spec; pub use arc_consensus_db as store; diff --git a/crates/malachite-app/src/main.rs b/crates/malachite-app/src/main.rs index cd1820c..d69e8b2 100644 --- a/crates/malachite-app/src/main.rs +++ b/crates/malachite-app/src/main.rs @@ -31,11 +31,13 @@ use arc_consensus_types::{ use arc_node_consensus::hardcoded_config; use arc_node_consensus::node::{App, StartConfig}; use arc_node_consensus::store::migrations::MigrationCoordinator; +use arc_node_consensus::store::{rollback_to_height, CERTIFICATES_TABLE, ROLLBACK_BATCH_SIZE}; use arc_node_consensus_cli::{ args::{Args, Commands}, cmd::{ db::DbCommands, db::MigrateCmd, + db::RollbackCmd, download::DownloadCmd, init::InitCmd, key::KeyCmd, @@ -43,6 +45,7 @@ use arc_node_consensus_cli::{ }, config, logging, runtime, }; +use redb::ReadableTable; #[cfg(not(target_env = "msvc"))] #[global_allocator] @@ -98,6 +101,7 @@ fn main() -> Result<()> { Commands::Db(db_cmd) => match db_cmd { DbCommands::Migrate(cmd) => db_migrate(&args, cmd), DbCommands::Compact => compact(&args), + DbCommands::Rollback(cmd) => rollback(&args, cmd), }, Commands::Download(cmd) => download(&args, cmd), } @@ -372,6 +376,104 @@ fn compact(args: &Args) -> Result<()> { Ok(()) } +fn rollback(args: &Args, cmd: &RollbackCmd) -> Result<()> { + info!("Starting database rollback"); + + let db_path = args.get_db_path()?; + if !db_path.exists() { + return Err(eyre!("Database file not found at {}", db_path.display())); + } + + info!(path = %db_path.display(), "Opening database"); + + let db = redb::Database::builder() + .open(&db_path) + .map_err(|e| eyre!("Failed to open database: {e}"))?; + + let current_height = { + let tx = db + .begin_read() + .map_err(|e| eyre!("Failed to start read transaction: {e}"))?; + let table = tx + .open_table(CERTIFICATES_TABLE) + .map_err(|e| eyre!("Failed to open certificates table: {e}"))?; + table + .last() + .map_err(|e| eyre!("Failed to read certificates tip: {e}"))? + .map(|(k, _)| k.value()) + .ok_or_else(|| eyre!("Consensus database is empty; nothing to roll back"))? + }; + + let target_height = match (cmd.num_heights, cmd.to_height) { + (Some(n), None) => { + if n == 0 { + return Err(eyre!("--num-heights must be greater than 0")); + } + current_height.saturating_sub(n) + } + (None, Some(h)) => Height::new(h), + _ => { + return Err(eyre!( + "Specify exactly one of --num-heights or --to-height " + )); + } + }; + + if target_height >= current_height { + return Err(eyre!( + "Target height {} is not below current height {}; nothing to roll back", + target_height, + current_height, + )); + } + + if target_height < Height::new(1) { + return Err(eyre!( + "Rolling back to height {} would erase genesis (height 1). \ + Minimum target height is 1.", + target_height, + )); + } + + let heights_to_remove = current_height + .as_u64() + .checked_sub(target_height.as_u64()) + .expect("target_height < current_height guarded above"); + + info!( + current_height = %current_height, + target_height = %target_height, + heights_to_remove = heights_to_remove, + execute = cmd.execute, + "Rolling back consensus database" + ); + + let dry_run = !cmd.execute; + let report = rollback_to_height(&db, target_height, ROLLBACK_BATCH_SIZE, dry_run) + .map_err(|e| eyre!("Rollback failed: {e}"))?; + + info!( + prior_height = %current_height, + target_height = %target_height, + certificates = report.certificates, + decided_blocks = report.decided_blocks, + invalid_payloads = report.invalid_payloads, + misbehavior_evidence = report.misbehavior_evidence, + proposal_monitor_data = report.proposal_monitor_data, + undecided_blocks = report.undecided_blocks, + pending_proposal_parts = report.pending_proposal_parts, + "Rollback report" + ); + + if dry_run { + info!("Dry run complete. To execute this rollback, re-run with --execute"); + } else { + info!("Database rollback completed successfully"); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/malachite-app/src/node.rs b/crates/malachite-app/src/node.rs index e1bd982..897f08b 100644 --- a/crates/malachite-app/src/node.rs +++ b/crates/malachite-app/src/node.rs @@ -54,7 +54,7 @@ use malachitebft_app_channel::app::config::{GossipSubConfig, PubSubProtocol}; use arc_consensus_types::codec::{network::NetCodec, wal::WalCodec}; use arc_consensus_types::signing::PublicKey; -use arc_consensus_types::{Address, ArcContext, Config, SigningConfig}; +use arc_consensus_types::{Address, ArcContext, ChainId, Config, ConsensusSpec, SigningConfig}; use arc_eth_engine::engine::Engine; use arc_eth_engine::json_structures::ExecutionBlock; use arc_node_consensus_cli::metrics; @@ -65,7 +65,6 @@ use crate::env_config::EnvConfig; use crate::hardcoded_config::{GossipLoad, GossipMeshParams}; use crate::metrics::{AppMetrics, DbMetrics, ProcessMetrics}; use crate::request::AppRequest; -use crate::spec::{ChainId, ConsensusSpec}; use crate::state::State; use crate::store::Store; use crate::utils::HaltAndWait; diff --git a/crates/malachite-app/src/state.rs b/crates/malachite-app/src/state.rs index 087e81e..8252486 100644 --- a/crates/malachite-app/src/state.rs +++ b/crates/malachite-app/src/state.rs @@ -28,7 +28,8 @@ use malachitebft_app_channel::app::types::core::Round; use crate::streaming; use arc_consensus_types::{ - Address, AlloyAddress, ArcContext, BlockHash, Config, ConsensusParams, Height, ValidatorSet, + Address, AlloyAddress, ArcContext, BlockHash, ChainId, Config, ConsensusParams, ConsensusSpec, + Height, NetworkId, ValidatorSet, }; use arc_eth_engine::json_structures::ExecutionBlock; use arc_eth_engine::persistence_meter::{NoopPersistenceMeter, PersistenceMeter}; @@ -40,7 +41,6 @@ use crate::env_config::EnvConfig; use crate::metrics::app::AppMetrics; use crate::node::ConsensusIdentity; use crate::request::Status; -use crate::spec::{ChainId, ConsensusSpec, NetworkId}; use crate::stats::Stats; use crate::store::repositories::UndecidedBlocksRepository; use crate::store::Store; @@ -140,7 +140,7 @@ pub struct State { /// Meters EL block persistence to apply backpressure during sync catch-up. persistence_meter: Box, - /// Consensus-layer chain spec (fork activation by height/time). + /// Consensus-layer chain spec (fork activation by height). #[allow(dead_code)] pub spec: ConsensusSpec, @@ -179,7 +179,7 @@ impl State { let network_id = NetworkId::new( spec.chain_id, genesis_block.block_hash, - spec.fork_version_at(initial_height, genesis_block.timestamp), + spec.fork_version_at(initial_height), ); Self { @@ -322,11 +322,7 @@ impl State { /// Recompute the network ID from the current chain ID, genesis hash, and fork version. #[must_use] fn recompute_network_id(&mut self) -> NetworkId { - let timestamp = self - .previous_block - .map(|b| b.timestamp) - .unwrap_or(self.genesis_block.timestamp); - let fork_version = self.spec.fork_version_at(self.current_height, timestamp); + let fork_version = self.spec.fork_version_at(self.current_height); self.network_id = NetworkId::new(self.chain_id(), self.genesis_block.block_hash, fork_version); diff --git a/crates/malachite-app/tests/cli_db_rollback.rs b/crates/malachite-app/tests/cli_db_rollback.rs new file mode 100644 index 0000000..3646b70 --- /dev/null +++ b/crates/malachite-app/tests/cli_db_rollback.rs @@ -0,0 +1,727 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +use arc_consensus_types::{Height, B256}; +use arc_node_consensus::store::{ + rollback_to_height, CERTIFICATES_TABLE, DECIDED_BLOCKS_TABLE, INVALID_PAYLOADS_TABLE, + MISBEHAVIOR_EVIDENCE_TABLE, PENDING_PROPOSAL_PARTS_TABLE, PROPOSAL_MONITOR_DATA_TABLE, + UNDECIDED_BLOCKS_TABLE, +}; +use assert_cmd::assert::OutputAssertExt; +use malachitebft_app_channel::app::types::core::Round; +use predicates::prelude::*; +use redb::ReadableTable; +use tempfile::tempdir; + +fn create_test_database(path: PathBuf) { + let db = redb::Database::builder() + .create(&path) + .expect("Failed to create test database"); + + let tx = db.begin_write().expect("Failed to begin write"); + { + let mut certificates = tx + .open_table(CERTIFICATES_TABLE) + .expect("Failed to open certificates table"); + let mut decided = tx + .open_table(DECIDED_BLOCKS_TABLE) + .expect("Failed to open decided blocks table"); + let mut invalid = tx + .open_table(INVALID_PAYLOADS_TABLE) + .expect("Failed to open invalid payloads table"); + let mut misbehavior = tx + .open_table(MISBEHAVIOR_EVIDENCE_TABLE) + .expect("Failed to open misbehavior evidence table"); + let mut proposal_monitor = tx + .open_table(PROPOSAL_MONITOR_DATA_TABLE) + .expect("Failed to open proposal monitor table"); + let mut undecided = tx + .open_table(UNDECIDED_BLOCKS_TABLE) + .expect("Failed to open undecided blocks table"); + let mut pending = tx + .open_table(PENDING_PROPOSAL_PARTS_TABLE) + .expect("Failed to open pending proposal parts table"); + + for h in 1u64..=3 { + let h_u8 = u8::try_from(h).expect("loop index fits in u8"); + let height = Height::new(h); + let payload = vec![h_u8]; + certificates + .insert(height, payload.clone()) + .expect("insert certificate"); + decided + .insert(height, payload.clone()) + .expect("insert decided block"); + invalid + .insert(height, payload.clone()) + .expect("insert invalid payload"); + misbehavior + .insert(height, payload.clone()) + .expect("insert misbehavior evidence"); + proposal_monitor + .insert(height, payload.clone()) + .expect("insert proposal monitor data"); + + let block_hash = B256::repeat_byte(h_u8); + undecided + .insert((height, Round::new(0), block_hash), payload.clone()) + .expect("insert undecided block"); + pending + .insert((height, Round::new(0), block_hash), payload) + .expect("insert pending proposal parts"); + } + } + tx.commit().expect("Failed to commit transaction"); +} + +#[test] +fn test_rollback_command_removes_recent_heights() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + create_test_database(db_path.clone()); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--num-heights", + "1", + "--execute", + ]) + .assert() + .success() + .stdout(predicate::str::contains( + "Database rollback completed successfully", + )); + + let db = redb::Database::builder() + .open(&db_path) + .expect("Failed to reopen test database"); + let tx = db.begin_read().expect("Failed to start read transaction"); + + // Height 3 must be gone from all tables + let certificates = tx.open_table(CERTIFICATES_TABLE).unwrap(); + assert_eq!( + certificates.last().unwrap().map(|(k, _)| k.value()), + Some(Height::new(2)) + ); + assert!(certificates.get(Height::new(3)).unwrap().is_none()); + + let decided = tx.open_table(DECIDED_BLOCKS_TABLE).unwrap(); + assert!(decided.get(Height::new(3)).unwrap().is_none()); + + let invalid = tx.open_table(INVALID_PAYLOADS_TABLE).unwrap(); + assert!(invalid.get(Height::new(3)).unwrap().is_none()); + + let misbehavior = tx.open_table(MISBEHAVIOR_EVIDENCE_TABLE).unwrap(); + assert!(misbehavior.get(Height::new(3)).unwrap().is_none()); + + let proposal_monitor = tx.open_table(PROPOSAL_MONITOR_DATA_TABLE).unwrap(); + assert!(proposal_monitor.get(Height::new(3)).unwrap().is_none()); + + let undecided = tx.open_table(UNDECIDED_BLOCKS_TABLE).unwrap(); + assert!(undecided + .get((Height::new(3), Round::new(0), B256::repeat_byte(3))) + .unwrap() + .is_none()); + + let pending = tx.open_table(PENDING_PROPOSAL_PARTS_TABLE).unwrap(); + assert!(pending + .get((Height::new(3), Round::new(0), B256::repeat_byte(3))) + .unwrap() + .is_none()); + + // Heights 1 and 2 must be intact in all tables + for h in [1u64, 2] { + let h_u8 = u8::try_from(h).expect("loop index fits in u8"); + let height = Height::new(h); + assert!( + certificates.get(height).unwrap().is_some(), + "certificates missing height {h}" + ); + assert!( + decided.get(height).unwrap().is_some(), + "decided_blocks missing height {h}" + ); + assert!( + invalid.get(height).unwrap().is_some(), + "invalid_payloads missing height {h}" + ); + assert!( + misbehavior.get(height).unwrap().is_some(), + "misbehavior_evidence missing height {h}" + ); + assert!( + proposal_monitor.get(height).unwrap().is_some(), + "proposal_monitor_data missing height {h}" + ); + assert!( + undecided + .get((height, Round::new(0), B256::repeat_byte(h_u8))) + .unwrap() + .is_some(), + "undecided_blocks missing height {h}" + ); + assert!( + pending + .get((height, Round::new(0), B256::repeat_byte(h_u8))) + .unwrap() + .is_some(), + "pending_proposal_parts missing height {h}" + ); + } +} + +#[test] +fn test_rollback_default_is_dry_run() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + create_test_database(db_path.clone()); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--num-heights", + "2", + ]) + .assert() + .success() + .stdout(predicate::str::contains("re-run with --execute")); + + // Database must be untouched + let db = redb::Database::builder() + .open(&db_path) + .expect("Failed to reopen test database"); + let tx = db.begin_read().expect("Failed to start read transaction"); + let certificates = tx.open_table(CERTIFICATES_TABLE).unwrap(); + + assert_eq!( + certificates.last().unwrap().map(|(k, _)| k.value()), + Some(Height::new(3)) + ); + assert!(certificates.get(Height::new(3)).unwrap().is_some()); +} + +#[test] +fn test_rollback_past_genesis_errors() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + create_test_database(db_path.clone()); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--num-heights", + "10", + "--execute", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("erase genesis")); + + // Database must be untouched + let db = redb::Database::builder() + .open(&db_path) + .expect("Failed to reopen test database"); + let tx = db.begin_read().expect("Failed to start read transaction"); + let certificates = tx.open_table(CERTIFICATES_TABLE).unwrap(); + assert_eq!( + certificates.last().unwrap().map(|(k, _)| k.value()), + Some(Height::new(3)) + ); +} + +#[test] +fn test_rollback_removes_all_composite_key_variations() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + + let h2 = Height::new(2); + let h3 = Height::new(3); + let hash_a = B256::repeat_byte(0xAA); + let hash_b = B256::repeat_byte(0xBB); + + // Seed: height 2 and 3 each get multiple (round, hash) combinations, + // plus a certificate so the CLI can read current_height. + { + let db = redb::Database::builder() + .create(&db_path) + .expect("create db"); + let tx = db.begin_write().expect("begin write"); + { + let mut certs = tx.open_table(CERTIFICATES_TABLE).expect("open certs"); + certs.insert(h2, vec![2u8]).expect("insert cert h2"); + certs.insert(h3, vec![3u8]).expect("insert cert h3"); + + let mut undecided = tx + .open_table(UNDECIDED_BLOCKS_TABLE) + .expect("open undecided"); + let mut pending = tx + .open_table(PENDING_PROPOSAL_PARTS_TABLE) + .expect("open pending"); + + // Height 2: two rounds, two hashes + for round in [Round::Nil, Round::new(0), Round::new(1)] { + for hash in [hash_a, hash_b] { + undecided + .insert((h2, round, hash), vec![2u8]) + .expect("insert undecided h2"); + pending + .insert((h2, round, hash), vec![2u8]) + .expect("insert pending h2"); + } + } + + // Height 3: same spread + for round in [Round::Nil, Round::new(0), Round::new(1)] { + for hash in [hash_a, hash_b] { + undecided + .insert((h3, round, hash), vec![3u8]) + .expect("insert undecided h3"); + pending + .insert((h3, round, hash), vec![3u8]) + .expect("insert pending h3"); + } + } + } + tx.commit().expect("commit"); + } + + // Roll back 1 height (remove height 3, keep height 2) + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--num-heights", + "1", + "--execute", + ]) + .assert() + .success() + .stdout(predicate::str::contains( + "Database rollback completed successfully", + )); + + let db = redb::Database::builder().open(&db_path).expect("reopen db"); + let tx = db.begin_read().expect("begin read"); + let undecided = tx.open_table(UNDECIDED_BLOCKS_TABLE).unwrap(); + let pending = tx.open_table(PENDING_PROPOSAL_PARTS_TABLE).unwrap(); + + // Every (round, hash) combination at height 3 must be gone + for round in [Round::Nil, Round::new(0), Round::new(1)] { + for hash in [hash_a, hash_b] { + assert!( + undecided.get((h3, round, hash)).unwrap().is_none(), + "undecided entry at h3/round={round:?}/hash={hash} should be deleted" + ); + assert!( + pending.get((h3, round, hash)).unwrap().is_none(), + "pending entry at h3/round={round:?}/hash={hash} should be deleted" + ); + } + } + + // Every (round, hash) combination at height 2 must survive + for round in [Round::Nil, Round::new(0), Round::new(1)] { + for hash in [hash_a, hash_b] { + assert!( + undecided.get((h2, round, hash)).unwrap().is_some(), + "undecided entry at h2/round={round:?}/hash={hash} should be retained" + ); + assert!( + pending.get((h2, round, hash)).unwrap().is_some(), + "pending entry at h2/round={round:?}/hash={hash} should be retained" + ); + } + } +} + +#[test] +fn test_rollback_with_batch_size_one() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("store.db"); + + let db = redb::Database::builder() + .create(&db_path) + .expect("create db"); + + // Seed heights 1..=5 in all 7 tables + { + let tx = db.begin_write().expect("begin write"); + { + let mut certs = tx.open_table(CERTIFICATES_TABLE).unwrap(); + let mut decided = tx.open_table(DECIDED_BLOCKS_TABLE).unwrap(); + let mut invalid = tx.open_table(INVALID_PAYLOADS_TABLE).unwrap(); + let mut misbehavior = tx.open_table(MISBEHAVIOR_EVIDENCE_TABLE).unwrap(); + let mut proposal_monitor = tx.open_table(PROPOSAL_MONITOR_DATA_TABLE).unwrap(); + let mut undecided = tx.open_table(UNDECIDED_BLOCKS_TABLE).unwrap(); + let mut pending = tx.open_table(PENDING_PROPOSAL_PARTS_TABLE).unwrap(); + + for h in 1u64..=5 { + let h_u8 = u8::try_from(h).expect("loop index fits in u8"); + let height = Height::new(h); + let payload = vec![h_u8]; + certs.insert(height, payload.clone()).unwrap(); + decided.insert(height, payload.clone()).unwrap(); + invalid.insert(height, payload.clone()).unwrap(); + misbehavior.insert(height, payload.clone()).unwrap(); + proposal_monitor.insert(height, payload.clone()).unwrap(); + + let hash = B256::repeat_byte(h_u8); + undecided + .insert((height, Round::new(0), hash), payload.clone()) + .unwrap(); + pending + .insert((height, Round::new(0), hash), payload) + .unwrap(); + } + } + tx.commit().unwrap(); + } + + // Roll back to height 2, batch_size=1 forces 3 separate transactions (heights 3, 4, 5) + let report = + rollback_to_height(&db, Height::new(2), 1, false).expect("rollback should succeed"); + + assert_eq!(report.certificates, 3); + assert_eq!(report.decided_blocks, 3); + assert_eq!(report.invalid_payloads, 3); + assert_eq!(report.misbehavior_evidence, 3); + assert_eq!(report.proposal_monitor_data, 3); + assert_eq!(report.undecided_blocks, 3); + assert_eq!(report.pending_proposal_parts, 3); + + let tx = db.begin_read().unwrap(); + + let certs = tx.open_table(CERTIFICATES_TABLE).unwrap(); + assert_eq!( + certs.last().unwrap().map(|(k, _)| k.value()), + Some(Height::new(2)) + ); + for h in 3u64..=5 { + assert!(certs.get(Height::new(h)).unwrap().is_none()); + } + for h in 1u64..=2 { + assert!(certs.get(Height::new(h)).unwrap().is_some()); + } + + let undecided = tx.open_table(UNDECIDED_BLOCKS_TABLE).unwrap(); + let pending = tx.open_table(PENDING_PROPOSAL_PARTS_TABLE).unwrap(); + for h in 3u64..=5 { + let h_u8 = u8::try_from(h).expect("loop index fits in u8"); + let key = (Height::new(h), Round::new(0), B256::repeat_byte(h_u8)); + assert!(undecided.get(key).unwrap().is_none()); + assert!(pending.get(key).unwrap().is_none()); + } + for h in 1u64..=2 { + let h_u8 = u8::try_from(h).expect("loop index fits in u8"); + let key = (Height::new(h), Round::new(0), B256::repeat_byte(h_u8)); + assert!(undecided.get(key).unwrap().is_some()); + assert!(pending.get(key).unwrap().is_some()); + } +} + +/// Regression test: batch_size that doesn't divide evenly into the deletion range. +/// Heights 1–7, target=2, batch_size=2 → 5 heights to delete (3,4,5,6,7). +/// Batches (high-to-low): [6,7], [4,5], [3]. The last partial batch must clamp +/// at the lower bound and not delete heights 1 or 2. +#[test] +fn test_rollback_uneven_batch_respects_lower_bound() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("store.db"); + + let db = redb::Database::builder() + .create(&db_path) + .expect("create db"); + + { + let tx = db.begin_write().expect("begin write"); + { + let mut certs = tx.open_table(CERTIFICATES_TABLE).unwrap(); + let mut decided = tx.open_table(DECIDED_BLOCKS_TABLE).unwrap(); + let mut invalid = tx.open_table(INVALID_PAYLOADS_TABLE).unwrap(); + let mut misbehavior = tx.open_table(MISBEHAVIOR_EVIDENCE_TABLE).unwrap(); + let mut proposal_monitor = tx.open_table(PROPOSAL_MONITOR_DATA_TABLE).unwrap(); + let mut undecided = tx.open_table(UNDECIDED_BLOCKS_TABLE).unwrap(); + let mut pending = tx.open_table(PENDING_PROPOSAL_PARTS_TABLE).unwrap(); + + for h in 1u64..=7 { + let h_u8 = u8::try_from(h).expect("loop index fits in u8"); + let height = Height::new(h); + let payload = vec![h_u8]; + certs.insert(height, payload.clone()).unwrap(); + decided.insert(height, payload.clone()).unwrap(); + invalid.insert(height, payload.clone()).unwrap(); + misbehavior.insert(height, payload.clone()).unwrap(); + proposal_monitor.insert(height, payload.clone()).unwrap(); + + let hash = B256::repeat_byte(h_u8); + undecided + .insert((height, Round::new(0), hash), payload.clone()) + .unwrap(); + pending + .insert((height, Round::new(0), hash), payload) + .unwrap(); + } + } + tx.commit().unwrap(); + } + + let report = + rollback_to_height(&db, Height::new(2), 2, false).expect("rollback should succeed"); + + assert_eq!(report.certificates, 5); + assert_eq!(report.decided_blocks, 5); + assert_eq!(report.invalid_payloads, 5); + assert_eq!(report.misbehavior_evidence, 5); + assert_eq!(report.proposal_monitor_data, 5); + assert_eq!(report.undecided_blocks, 5); + assert_eq!(report.pending_proposal_parts, 5); + + let tx = db.begin_read().unwrap(); + + // Heights 3–7 must be deleted + let certs = tx.open_table(CERTIFICATES_TABLE).unwrap(); + for h in 3u64..=7 { + assert!( + certs.get(Height::new(h)).unwrap().is_none(), + "height {h} should be deleted" + ); + } + + // Heights 1–2 must be intact across all tables + let decided = tx.open_table(DECIDED_BLOCKS_TABLE).unwrap(); + let invalid = tx.open_table(INVALID_PAYLOADS_TABLE).unwrap(); + let misbehavior = tx.open_table(MISBEHAVIOR_EVIDENCE_TABLE).unwrap(); + let proposal_monitor = tx.open_table(PROPOSAL_MONITOR_DATA_TABLE).unwrap(); + let undecided = tx.open_table(UNDECIDED_BLOCKS_TABLE).unwrap(); + let pending = tx.open_table(PENDING_PROPOSAL_PARTS_TABLE).unwrap(); + + for h in 1u64..=2 { + let h_u8 = u8::try_from(h).expect("loop index fits in u8"); + let height = Height::new(h); + assert!( + certs.get(height).unwrap().is_some(), + "certs missing height {h}" + ); + assert!( + decided.get(height).unwrap().is_some(), + "decided missing height {h}" + ); + assert!( + invalid.get(height).unwrap().is_some(), + "invalid missing height {h}" + ); + assert!( + misbehavior.get(height).unwrap().is_some(), + "misbehavior missing height {h}" + ); + assert!( + proposal_monitor.get(height).unwrap().is_some(), + "proposal_monitor missing height {h}" + ); + let key = (height, Round::new(0), B256::repeat_byte(h_u8)); + assert!( + undecided.get(key).unwrap().is_some(), + "undecided missing height {h}" + ); + assert!( + pending.get(key).unwrap().is_some(), + "pending missing height {h}" + ); + } + + assert_eq!( + certs.last().unwrap().map(|(k, _)| k.value()), + Some(Height::new(2)) + ); +} + +#[test] +fn test_rollback_to_height_flag() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + create_test_database(db_path.clone()); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--to-height", + "1", + "--execute", + ]) + .assert() + .success() + .stdout(predicate::str::contains( + "Database rollback completed successfully", + )); + + let db = redb::Database::builder() + .open(&db_path) + .expect("Failed to reopen test database"); + let tx = db.begin_read().expect("Failed to start read transaction"); + let certificates = tx.open_table(CERTIFICATES_TABLE).unwrap(); + + // Only height 1 should remain + assert_eq!( + certificates.last().unwrap().map(|(k, _)| k.value()), + Some(Height::new(1)) + ); + assert!(certificates.get(Height::new(2)).unwrap().is_none()); + assert!(certificates.get(Height::new(3)).unwrap().is_none()); +} + +#[test] +fn test_rollback_to_height_dry_run() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + create_test_database(db_path.clone()); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--to-height", + "1", + ]) + .assert() + .success() + .stdout(predicate::str::contains("re-run with --execute")); + + // Database must be untouched + let db = redb::Database::builder() + .open(&db_path) + .expect("Failed to reopen test database"); + let tx = db.begin_read().expect("Failed to start read transaction"); + let certificates = tx.open_table(CERTIFICATES_TABLE).unwrap(); + assert_eq!( + certificates.last().unwrap().map(|(k, _)| k.value()), + Some(Height::new(3)) + ); +} + +#[test] +fn test_rollback_conflicting_flags_errors() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + create_test_database(db_path); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--num-heights", + "1", + "--to-height", + "2", + "--execute", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); +} + +#[test] +fn test_rollback_neither_flag_errors() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + create_test_database(db_path); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--execute", + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "Specify exactly one of --num-heights", + )); +} + +#[test] +fn test_rollback_to_height_above_current_errors() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + create_test_database(db_path); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--to-height", + "100", + "--execute", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("nothing to roll back")); +} + +#[test] +fn test_rollback_to_height_zero_errors() { + let dir = tempdir().unwrap(); + let home_dir = dir.path(); + fs::create_dir_all(home_dir).unwrap(); + let db_path = home_dir.join("store.db"); + create_test_database(db_path); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("arc-node-consensus")); + cmd.args([ + "db", + "rollback", + "--home", + home_dir.to_str().unwrap(), + "--to-height", + "0", + "--execute", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("erase genesis")); +} diff --git a/crates/malachite-cli/src/cmd/db.rs b/crates/malachite-cli/src/cmd/db.rs index b54d78c..77795c0 100644 --- a/crates/malachite-cli/src/cmd/db.rs +++ b/crates/malachite-cli/src/cmd/db.rs @@ -26,6 +26,10 @@ pub enum DbCommands { /// Compact the database to reclaim space. The node must be stopped before running this command. Compact, + + /// Roll back the database by removing heights. Dry-run by default; pass --execute to commit. + #[clap(alias = "unwind")] + Rollback(RollbackCmd), } #[derive(Args, Clone, Debug, Default)] @@ -34,3 +38,20 @@ pub struct MigrateCmd { #[arg(long)] pub dry_run: bool, } + +#[derive(Args, Clone, Debug, Default)] +pub struct RollbackCmd { + /// Number of heights to remove from the tip of the consensus DB. + /// Mutually exclusive with --to-height. + #[arg(long, value_name = "COUNT", conflicts_with = "to_height")] + pub num_heights: Option, + + /// Absolute height to roll back to. All data above this height is removed. + /// Mutually exclusive with --num-heights. + #[arg(long, value_name = "HEIGHT", conflicts_with = "num_heights")] + pub to_height: Option, + + /// Actually execute the rollback. Without this flag the command is a dry run. + #[arg(long)] + pub execute: bool, +} diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 39170c1..13bf4a9 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -56,6 +56,7 @@ reth-node-ethereum.workspace = true reth-node-metrics = { workspace = true, optional = true, features = ["jemalloc"] } reth-prune-types.workspace = true reth-rpc-eth-types.workspace = true +reth-rpc-server-types.workspace = true # revm revm.workspace = true @@ -81,6 +82,7 @@ arc-execution-config = { workspace = true, features = ["test-utils"] } arc-precompiles.workspace = true reth-e2e-test-utils.workspace = true reth-rpc-eth-types.workspace = true +tracing-test = "0.2" [lints] workspace = true diff --git a/crates/node/README.md b/crates/node/README.md index ffa40ff..48988ff 100644 --- a/crates/node/README.md +++ b/crates/node/README.md @@ -109,7 +109,8 @@ In addition to standard Reth flags, `arc-node-execution` provides the following | `--invalid-tx-list-cap ` | `100000` | - | Maximum capacity of the invalid tx list LRU cache. Only read if `--invalid-tx-list-enable` is set | | `--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.hide-pending-txs` | `false` | - | Hide pending-tx RPCs. When set, a filter blocks pending-tx subscriptions, filters, and pending block queries (see [Pending Txs Filter](#pending-txs-filter)). | +| `--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`. | **Examples:** @@ -182,9 +183,12 @@ arc-node-execution node \ ## Pending Txs Filter -By default, the node allows all pending-tx RPCs. Pass `--arc.hide-pending-txs` to enable the filter that blocks them (for externally-exposed nodes). +By default, the node hides pending-tx RPCs. Pass `--arc.expose-pending-txs` +to disable the filter on trusted / internal nodes. External / public-facing +nodes should instead use `--public-api`, which also narrows the advised +RPC surface. -**When the filter is enabled (`--arc.hide-pending-txs`):** +**When the filter is enabled (default, or enforced by `--public-api`):** | Method or call | Behavior | |----------------|----------| @@ -192,6 +196,10 @@ By default, the node allows all pending-tx RPCs. Pass `--arc.hide-pending-txs` t | `eth_newPendingTransactionFilter` | Error -32001 | | `eth_getBlockByNumber("pending")` | Returns `null` (success) | +**When the filter is disabled (`--arc.expose-pending-txs`):** + +All three methods bypass the middleware. Pending-block queries additionally depend on `--rpc.pending-block` (Arc default: `none`); to actually receive pending-block data, set `--rpc.pending-block=full` alongside `--arc.expose-pending-txs`. + ## Architecture The execution layer is built on top of [Reth][reth], extending it with Arc-specific functionality: diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs index a11b35e..df6ea9b 100644 --- a/crates/node/src/main.rs +++ b/crates/node/src/main.rs @@ -43,8 +43,10 @@ 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_rpc_server_types::{RethRpcModule, RpcModuleSelection}; use tracing::info; +use std::collections::HashSet; use std::sync::Arc; use reth_node_core::args::DefaultPruningValues; @@ -82,6 +84,20 @@ impl ArcCli { if node_cmd.builder.extra_data != default_extra_data() { return Err("--builder.extradata is not supported"); } + + // The middleware intercepts `eth_getBlockByNumber("pending")` on single + // calls only; batch calls fall through to Reth and rely on + // `--rpc.pending-block=none` to return null. Reject the asymmetric + // combination that would leak pending-block data via batch. + if compute_filter_pending_txs(&node_cmd.ext) + && node_cmd.rpc.rpc_pending_block + != reth_rpc_eth_types::builder::config::PendingBlockKind::None + { + return Err( + "--rpc.pending-block must be 'none' when the pending-tx filter is active; \ + pass --arc.expose-pending-txs to opt out of hiding or set --rpc.pending-block=none", + ); + } } Ok(()) } @@ -239,18 +255,31 @@ struct ArcExtraCli { )] arc_denylist_addresses_exclusions: Vec, - /// Hide pending-tx RPCs (subscriptions, filters, and pending block queries). + /// Expose pending-tx RPCs on externally-reachable sockets. /// - /// When set, the middleware blocks newPendingTransactions subscriptions, - /// eth_newPendingTransactionFilter, and returns null for - /// eth_getBlockByNumber("pending"). Use on externally-exposed nodes - /// for MEV protection. + /// Off by default: the middleware blocks `eth_subscribe("newPendingTransactions")`, + /// `eth_newPendingTransactionFilter`, and returns null for + /// `eth_getBlockByNumber("pending")`. Set this flag on trusted / internal + /// nodes where exposing pending-tx state is desired (e.g. debugging). #[arg( - long = "arc.hide-pending-txs", + long = "arc.expose-pending-txs", default_value_t = false, help_heading = "Arc RPC" )] - arc_hide_pending_txs: bool, + arc_expose_pending_txs: bool, + + /// Convenience flag for externally-exposed RPC nodes. + /// + /// Forces hiding of pending-tx RPCs. Conflicts with + /// `--arc.expose-pending-txs`, and warns at startup if `--http.api` or + /// `--ws.api` exposes namespaces outside `{eth, net, web3, rpc}`. + #[arg( + long = "public-api", + default_value_t = false, + conflicts_with = "arc_expose_pending_txs", + help_heading = "Arc RPC" + )] + public_api: bool, /// Interval in seconds between transaction rebroadcast rounds. /// @@ -333,6 +362,48 @@ fn build_addresses_denylist_config(ext: &ArcExtraCli) -> eyre::Result Vec { + let safe: HashSet = PUBLIC_API_SAFE_MODULES.into_iter().collect(); + selection + .to_selection() + .into_iter() + .filter(|m| !safe.contains(m)) + .collect() +} + +/// Emits a `warn!` if `selection` contains modules outside the safe set. +/// `None` is safe: Reth's default is `Standard` = {eth, net, web3}, a subset of our safe set. +fn warn_if_public_api_unsafe(selection: Option<&RpcModuleSelection>, socket_flag: &str) { + let Some(sel) = selection else { return }; + let unsafe_modules = unsafe_public_api_modules(sel); + if !unsafe_modules.is_empty() { + let names: Vec = unsafe_modules.iter().map(|m| m.to_string()).collect(); + tracing::warn!( + "--public-api set but {socket_flag} exposes sensitive namespaces: {names:?}. \ + Consider dropping them or removing --public-api to acknowledge the risk." + ); + } +} + +/// Computes whether the pending-tx RPC filter should be active for this run. +/// `--public-api` wins; clap enforces it can't coexist with `--arc.expose-pending-txs`. +fn compute_filter_pending_txs(ext: &ArcExtraCli) -> bool { + ext.public_api || !ext.arc_expose_pending_txs +} + /// Number of bodies, receipts, etc. to retain after pruning. /// See init_arc_pruning for more details. const PRESETS_PRUNE_DISTANCE: u64 = 237_600; @@ -458,6 +529,12 @@ fn main() { InvalidTxListConfig::new(ext.invalid_tx_list_enable, ext.invalid_tx_list_cap); let payload_builder_deadline_ms = ext.payload_builder_deadline_ms; + if ext.public_api { + let rpc = &builder.config().rpc; + warn_if_public_api_unsafe(rpc.http_api.as_ref(), "--http.api"); + warn_if_public_api_unsafe(rpc.ws_api.as_ref(), "--ws.api"); + } + // Run an RPC node if enabled (unsafe - no verification) if let Some(ref unsafe_follow_url) = ext.unsafe_follow_url { follow_url_for_consensus(&mut builder, unsafe_follow_url)?; @@ -474,7 +551,7 @@ fn main() { arc_node_execution::metrics::register_version_info(); let wait_for_payload = ext.wait_for_payload; - let filter_pending_txs = ext.arc_hide_pending_txs; + let filter_pending_txs = compute_filter_pending_txs(&ext); let rebroadcast_interval = std::time::Duration::from_secs(ext.txpool_rebroadcast_interval); let handle = builder @@ -598,24 +675,74 @@ mod tests { use super::*; use alloy_primitives::{address, b256}; + /// Parse CLI args with `patch_node_command_defaults` applied (mirrors production). + fn parse_with_arc_defaults(argv: I) -> ArcCli + where + I: IntoIterator, + { + let patched = patch_node_command_defaults(ArcCli::command()); + ArcCli::from_arg_matches(&patched.get_matches_from(argv)).unwrap() + } + #[test] fn test_extradata_default_is_allowed() { - let cli = ArcCli::try_parse_from(["arc-node-execution", "node"]).unwrap(); + let cli = parse_with_arc_defaults(["arc-node-execution", "node"]); assert!(cli.validate().is_ok()); } #[test] fn test_extradata_custom_is_rejected() { - let cli = ArcCli::try_parse_from([ + let cli = parse_with_arc_defaults([ "arc-node-execution", "node", "--builder.extradata", "custom", - ]) - .unwrap(); + ]); assert_eq!(cli.validate(), Err("--builder.extradata is not supported")); } + #[test] + fn test_validate_rejects_filter_with_pending_block_full() { + let cli = + parse_with_arc_defaults(["arc-node-execution", "node", "--rpc.pending-block=full"]); + assert!( + cli.validate() + .unwrap_err() + .contains("--rpc.pending-block must be 'none'"), + "default filter + --rpc.pending-block=full must be rejected" + ); + } + + #[test] + fn test_validate_allows_expose_with_pending_block_full() { + let cli = parse_with_arc_defaults([ + "arc-node-execution", + "node", + "--arc.expose-pending-txs", + "--rpc.pending-block=full", + ]); + assert!( + cli.validate().is_ok(), + "--arc.expose-pending-txs + --rpc.pending-block=full must be allowed" + ); + } + + #[test] + fn test_validate_rejects_public_api_with_pending_block_full() { + let cli = parse_with_arc_defaults([ + "arc-node-execution", + "node", + "--public-api", + "--rpc.pending-block=full", + ]); + assert!( + cli.validate() + .unwrap_err() + .contains("--rpc.pending-block must be 'none'"), + "--public-api + --rpc.pending-block=full must be rejected" + ); + } + #[test] fn test_pending_block_default_is_none() { let patched = patch_node_command_defaults(ArcCli::command()); @@ -960,12 +1087,12 @@ mod tests { } #[test] - fn test_arc_hide_pending_txs_default_is_false() { + fn test_arc_expose_pending_txs_default_is_false() { 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_hide_pending_txs, - "Default: --arc.hide-pending-txs should be false" + !node_cmd.ext.arc_expose_pending_txs, + "Default: --arc.expose-pending-txs should be false (pending txs hidden by default)" ); } else { panic!("Expected Node command"); @@ -973,13 +1100,96 @@ mod tests { } #[test] - fn test_arc_hide_pending_txs_when_set() { - let cli = ArcCli::try_parse_from(["arc-node-execution", "node", "--arc.hide-pending-txs"]) - .unwrap(); + fn test_arc_expose_pending_txs_when_set() { + let cli = + ArcCli::try_parse_from(["arc-node-execution", "node", "--arc.expose-pending-txs"]) + .unwrap(); if let Commands::Node(node_cmd) = cli.inner.command { assert!( - node_cmd.ext.arc_hide_pending_txs, - "--arc.hide-pending-txs should enable filtering" + node_cmd.ext.arc_expose_pending_txs, + "--arc.expose-pending-txs should flip the flag" + ); + } else { + panic!("Expected Node command"); + } + } + + #[test] + fn test_public_api_default_is_false() { + let cli = ArcCli::try_parse_from(["arc-node-execution", "node"]).unwrap(); + if let Commands::Node(node_cmd) = cli.inner.command { + assert!( + !node_cmd.ext.public_api, + "Default: --public-api should be false" + ); + } else { + panic!("Expected Node command"); + } + } + + #[test] + fn test_public_api_when_set() { + let cli = ArcCli::try_parse_from(["arc-node-execution", "node", "--public-api"]).unwrap(); + if let Commands::Node(node_cmd) = cli.inner.command { + assert!(node_cmd.ext.public_api, "--public-api should flip the flag"); + } else { + panic!("Expected Node command"); + } + } + + #[test] + fn test_public_api_conflicts_with_expose_pending_txs() { + use clap::error::ErrorKind; + + let err = ArcCli::try_parse_from([ + "arc-node-execution", + "node", + "--public-api", + "--arc.expose-pending-txs", + ]) + .unwrap_err(); + assert_eq!( + err.kind(), + ErrorKind::ArgumentConflict, + "clap should reject --public-api + --arc.expose-pending-txs as a conflict" + ); + } + + #[test] + fn test_public_api_enables_filter() { + let cli = ArcCli::try_parse_from(["arc-node-execution", "node", "--public-api"]).unwrap(); + if let Commands::Node(node_cmd) = cli.inner.command { + assert!( + compute_filter_pending_txs(&node_cmd.ext), + "--public-api alone must enable the filter" + ); + } else { + panic!("Expected Node command"); + } + } + + #[test] + fn test_compute_filter_pending_txs_default_hides() { + let cli = ArcCli::try_parse_from(["arc-node-execution", "node"]).unwrap(); + if let Commands::Node(node_cmd) = cli.inner.command { + assert!( + compute_filter_pending_txs(&node_cmd.ext), + "default config must keep the filter on" + ); + } else { + panic!("Expected Node command"); + } + } + + #[test] + fn test_compute_filter_pending_txs_expose_disables() { + let cli = + ArcCli::try_parse_from(["arc-node-execution", "node", "--arc.expose-pending-txs"]) + .unwrap(); + if let Commands::Node(node_cmd) = cli.inner.command { + assert!( + !compute_filter_pending_txs(&node_cmd.ext), + "--arc.expose-pending-txs must disable the filter" ); } else { panic!("Expected Node command"); @@ -1186,3 +1396,76 @@ mod tests { } } } + +#[cfg(test)] +mod public_api_tests { + use super::*; + + #[test] + fn unsafe_modules_empty_when_selection_is_subset_of_safe() { + let sel = RpcModuleSelection::try_from_selection(["eth", "net", "web3", "rpc"]).unwrap(); + let unsafe_ = unsafe_public_api_modules(&sel); + assert!(unsafe_.is_empty(), "eth/net/web3/rpc are all safe"); + } + + #[test] + fn unsafe_modules_lists_sensitive_namespaces() { + let sel = RpcModuleSelection::try_from_selection([ + "eth", "net", "web3", "txpool", "debug", "trace", "admin", + ]) + .unwrap(); + let unsafe_: HashSet<_> = unsafe_public_api_modules(&sel).into_iter().collect(); + assert_eq!( + unsafe_, + HashSet::from([ + RethRpcModule::Txpool, + RethRpcModule::Debug, + RethRpcModule::Trace, + RethRpcModule::Admin, + ]) + ); + } + + #[test] + fn unsafe_modules_treats_other_as_unsafe() { + let sel = RpcModuleSelection::try_from_selection(["eth", "custom"]).unwrap(); + let unsafe_ = unsafe_public_api_modules(&sel); + assert_eq!(unsafe_.len(), 1); + assert!(matches!(unsafe_[0], RethRpcModule::Other(_))); + } + + #[test] + fn unsafe_modules_handles_all_selection() { + let sel = RpcModuleSelection::All; + let unsafe_ = unsafe_public_api_modules(&sel); + assert!(!unsafe_.is_empty()); + assert!(!unsafe_.contains(&RethRpcModule::Eth)); + assert!(!unsafe_.contains(&RethRpcModule::Rpc)); + } + + #[tracing_test::traced_test] + #[test] + fn warn_if_public_api_unsafe_none_is_silent() { + warn_if_public_api_unsafe(None, "--http.api"); + assert!(!logs_contain("sensitive namespaces")); + } + + #[tracing_test::traced_test] + #[test] + fn warn_if_public_api_unsafe_safe_selection_is_silent() { + let sel = RpcModuleSelection::try_from_selection(["eth", "net", "web3"]).unwrap(); + warn_if_public_api_unsafe(Some(&sel), "--http.api"); + assert!(!logs_contain("sensitive namespaces")); + } + + #[tracing_test::traced_test] + #[test] + fn warn_if_public_api_unsafe_unsafe_selection_warns() { + let sel = RpcModuleSelection::try_from_selection(["eth", "txpool", "debug"]).unwrap(); + warn_if_public_api_unsafe(Some(&sel), "--ws.api"); + assert!(logs_contain("sensitive namespaces")); + assert!(logs_contain("--ws.api")); + assert!(logs_contain("txpool")); + assert!(logs_contain("debug")); + } +} diff --git a/crates/precompiles/Cargo.toml b/crates/precompiles/Cargo.toml index 1876972..2ec2074 100644 --- a/crates/precompiles/Cargo.toml +++ b/crates/precompiles/Cargo.toml @@ -43,10 +43,16 @@ thiserror.workspace = true alloy-consensus.workspace = true arc-evm.workspace = true arc-execution-config = { workspace = true, features = ["test-utils"] } +criterion.workspace = true reth-chainspec.workspace = true reth-ethereum-forks.workspace = true revm-context-interface.workspace = true serde_with.workspace = true +sha2.workspace = true + +[[bench]] +name = "pq" +harness = false [lints] workspace = true diff --git a/crates/precompiles/benches/pq.rs b/crates/precompiles/benches/pq.rs new file mode 100644 index 0000000..9e2559d --- /dev/null +++ b/crates/precompiles/benches/pq.rs @@ -0,0 +1,93 @@ +// 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::keccak256; +use criterion::{criterion_group, criterion_main, Criterion}; +use sha2::{Digest, Sha256}; +use slh_dsa::{ + signature::{Keypair, Signer, Verifier}, + Sha2_128s, Signature, SigningKey, VerifyingKey as SlhDsaVerifyingKey, +}; +use std::hint::black_box; + +const HASH_INPUT_BYTES: usize = 64; + +// A single worst-case SLH-DSA-SHA2-128s verification performs ~4,000 SHA-256 +// compression-block-equivalent calls with `slh-dsa`'s pk_seed midstate caching. +// The benchmark below compares the real verifier against one 64-byte SHA-256 +// digest. On the short local PR calibration run, SLH-DSA measured ~87.1 us +// while ~4,000 * sha256_64_bytes measured ~901 us. The gas comparison is +// intentionally more conservative: 230,000 gas is greater than ~4,000 * 24 = +// ~96,000 pure SHA-256 work gas. + +struct PqBenchmarkVector { + verifying_key: SlhDsaVerifyingKey, + message: [u8; 32], + signature: Signature, +} + +fn pq_benchmark_vector() -> PqBenchmarkVector { + 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 message = [0xA5; 32]; + let signature = signing_key.sign(&message); + + PqBenchmarkVector { + verifying_key: signing_key.verifying_key().clone(), + message, + signature, + } +} + +fn benchmark_pq(c: &mut Criterion) { + let vector = pq_benchmark_vector(); + let hash_input = [0xA5; HASH_INPUT_BYTES]; + + let mut group = c.benchmark_group("pq"); + + group.bench_function("slh_dsa_sha2_128s_verify", |b| { + b.iter(|| { + let is_valid = black_box(&vector.verifying_key) + .verify( + black_box(vector.message.as_slice()), + black_box(&vector.signature), + ) + .is_ok(); + black_box(is_valid) + }); + }); + + group.bench_function("sha256_64_bytes", |b| { + b.iter(|| { + let digest = Sha256::digest(black_box(hash_input.as_slice())); + black_box(digest) + }); + }); + + group.bench_function("keccak256_64_bytes", |b| { + b.iter(|| { + let digest = keccak256(black_box(hash_input.as_slice())); + black_box(digest) + }); + }); + + group.finish(); +} + +criterion_group!(benches, benchmark_pq); +criterion_main!(benches); diff --git a/crates/precompiles/src/call_from.rs b/crates/precompiles/src/call_from.rs index b220370..df40637 100644 --- a/crates/precompiles/src/call_from.rs +++ b/crates/precompiles/src/call_from.rs @@ -62,7 +62,7 @@ sol! { /// CallFrom precompile interface. interface ICallFrom { /// Execute a call to `target` with `data` as calldata, preserving `sender` as msg.sender. - function callFrom(address sender, address target, bytes data) external returns (bool success, bytes memory returnData); + function callFrom(address sender, address target, bytes calldata data) external returns (bool success, bytes memory returnData); } } diff --git a/crates/precompiles/src/helpers.rs b/crates/precompiles/src/helpers.rs index 5df5c51..15474de 100644 --- a/crates/precompiles/src/helpers.rs +++ b/crates/precompiles/src/helpers.rs @@ -99,6 +99,23 @@ impl PrecompileErrorOrRevert { } } +/// 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 + } + } else { + PRECOMPILE_SLOAD_GAS_COST + } +} + pub(crate) fn record_cost_or_out_of_gas( gas_counter: &mut Gas, cost: u64, @@ -273,40 +290,37 @@ pub(crate) fn transfer( to: Address, amount: U256, gas_counter: &mut Gas, - is_burn: bool, - check_selfdestructed: bool, + hardfork_flags: ArcHardforkFlags, ) -> Result<(), PrecompileErrorOrRevert> { - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SLOAD_GAS_COST)?; let loaded_from_account = internals.load_account(from).map_err(|_| { PrecompileErrorOrRevert::Error(PrecompileError::Other(ERR_EXECUTION_REVERTED.into())) })?; + record_cost_or_out_of_gas( + gas_counter, + account_load_cost(loaded_from_account.is_cold, hardfork_flags), + )?; // Check that the account can be decremented by the amount check_can_decr_account(&loaded_from_account.info, amount, gas_counter)?; - // Overflow checking is handled by the Journal and the TransferError - // returned - // Here we stack the STORE gas cost, mimicking the prior balance_decr + balance_incr calls, - // where we charged: - // SLOAD, SSTORE - // SLOAD, SSTORE - // For burns, we only charge the first SLOAD, SSTORE to mimick balance_decr + // Charge SLOAD + SSTORE for both accounts, mimicking the prior balance_decr + + // balance_incr sequence. Pre-Zero6 each SLOAD is flat; Zero6+ uses cold/warm pricing + // 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( + gas_counter, + account_load_cost(to_load.is_cold, hardfork_flags), + )?; record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SSTORE_GAS_COST)?; - if !is_burn { - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SLOAD_GAS_COST)?; - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SSTORE_GAS_COST)?; - } - if check_selfdestructed { - let to_account = internals.load_account(to).map_err(|_| { - PrecompileErrorOrRevert::Error(PrecompileError::Other(ERR_EXECUTION_REVERTED.into())) - })?; - if to_account.is_selfdestructed() { - return Err(PrecompileErrorOrRevert::new_reverted( - *gas_counter, - ERR_SELFDESTRUCTED_BALANCE_INCREASED, - )); - } + if hardfork_flags.is_active(ArcHardfork::Zero5) && to_load.is_selfdestructed() { + return Err(PrecompileErrorOrRevert::new_reverted( + *gas_counter, + ERR_SELFDESTRUCTED_BALANCE_INCREASED, + )); } let transfer_result = internals.transfer(from, to, amount).map_err(|_e| { @@ -339,16 +353,18 @@ pub(crate) fn balance_incr( to: Address, amount: U256, gas_counter: &mut Gas, - check_selfdestructed: bool, + hardfork_flags: ArcHardforkFlags, ) -> Result<(), PrecompileErrorOrRevert> { - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SLOAD_GAS_COST)?; - // 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), + )?; - if check_selfdestructed && account.is_selfdestructed() { + if hardfork_flags.is_active(ArcHardfork::Zero5) && account.is_selfdestructed() { return Err(PrecompileErrorOrRevert::new_reverted( *gas_counter, ERR_SELFDESTRUCTED_BALANCE_INCREASED, @@ -378,11 +394,15 @@ pub(crate) fn balance_decr( from: Address, amount: U256, gas_counter: &mut Gas, + hardfork_flags: ArcHardforkFlags, ) -> Result<(), PrecompileErrorOrRevert> { - record_cost_or_out_of_gas(gas_counter, PRECOMPILE_SLOAD_GAS_COST)?; let loaded_from_account = internals.load_account(from).map_err(|_| { PrecompileErrorOrRevert::Error(PrecompileError::Other(ERR_EXECUTION_REVERTED.into())) })?; + record_cost_or_out_of_gas( + gas_counter, + account_load_cost(loaded_from_account.is_cold, hardfork_flags), + )?; // Check that the account can be decremented by the amount check_can_decr_account(&loaded_from_account.info, amount, gas_counter)?; @@ -424,7 +444,7 @@ pub(crate) fn check_staticcall( pub(crate) fn check_delegatecall( precompile_address: Address, precompile_input: &PrecompileInput, - gas_counter: &mut Gas, + gas_counter: &Gas, ) -> Result<(), PrecompileErrorOrRevert> { if precompile_input.target_address != precompile_address || precompile_input.bytecode_address != precompile_address diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 98c04a6..311c415 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -22,32 +22,6 @@ //! //! ## Types of Precompiles //! -//! ### Stateless Precompiles -//! These are simple precompiles that perform computations without modifying state. -//! They are ideal for: -//! - Cryptographic operations -//! - Mathematical/deterministic computations -//! - Data transformations -//! -//! Example: -//! ```rust,ignore -//! // Define the interface using Solidity ABI -//! sol! { -//! interface IStatelessPrecompile { -//! function doSomething(uint256 first, uint8 second, bool third, string memory message) -//! external returns (uint256 result); -//! } -//! } -//! -//! // Implement using the stateless! macro -//! stateless!(stateless_precompile_fn, input, gas_limit; { -//! IStatelessPrecompile::doSomethingCall => |call| { -//! // Access decoded parameters directly -//! call.first + U256::from(call.second) + U256::from(call.third) + U256::from(call.message.len()) -//! }, -//! }); -//! ``` -//! //! ### Stateful Precompiles //! These precompiles can read from and write to storage, making them suitable for: //! - Managing on-chain state @@ -64,20 +38,38 @@ //! interface IStatefulPrecompile { //! function increment() external returns (uint256 newValue); //! function getCounter() external view returns (uint256 value); -//! function setCounter(uint256 newValue) external returns (uint256 previousValue); //! } //! } //! -//! // Implement using the stateful! macro -//! stateful!(run_stateful_precompile, context, inputs, gas_limit; { -//! IStatefulPrecompile::incrementCall => |_call| { -//! // Read current value -//! let (output, gas_counter) = read(context, ADDRESS, COUNTER_STORAGE_KEY, gas_limit)?; -//! let current = U256::from_be_slice(&output.bytes); -//! -//! // Write new value -//! let new_value = current + U256::from(1); -//! write(context, ADDRESS, COUNTER_STORAGE_KEY, &new_value.to_be_bytes_vec(), gas_counter) +//! // Implement using the precompile! macro. Each arm receives the calldata bytes +//! // following the 4-byte selector (`input` below) and must evaluate to +//! // `Result`. +//! precompile!(run_stateful_precompile, precompile_input, hardfork_flags; { +//! IStatefulPrecompile::incrementCall => |_input| { +//! (|| -> Result { +//! let mut gas_counter = Gas::new(precompile_input.gas); +//! let mut precompile_input = precompile_input; +//! +//! let output = read( +//! &mut precompile_input.internals, +//! ADDRESS, +//! COUNTER_STORAGE_KEY, +//! &mut gas_counter, +//! hardfork_flags, +//! )?; +//! let new_value = U256::from_be_slice(&output) + U256::from(1); +//! +//! write( +//! &mut precompile_input.internals, +//! ADDRESS, +//! 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())) +//! })() //! }, //! }); //! ``` @@ -101,61 +93,85 @@ //! ``` //! //! ### Step 3: Implement the Logic -//! For stateless precompiles: -//! ```rust,ignore -//! stateless!(my_precompile_fn, input, gas_limit; { -//! IMyPrecompile::myFunctionCall => |call| { -//! // Your logic here -//! call.param * U256::from(2) -//! }, -//! }); -//! ``` //! -//! For stateful precompiles: //! ```rust,ignore -//! stateful!(run_my_precompile, context, inputs, gas_limit; { -//! IMyPrecompile::myFunctionCall => |call| { -//! // Read/write storage as needed -//! read(context, MY_PRECOMPILE_ADDRESS, StorageKey::from(0), gas_limit) +//! precompile!(run_my_precompile, precompile_input, hardfork_flags; { +//! IMyPrecompile::myFunctionCall => |input| { +//! (|| -> Result { +//! let mut gas_counter = Gas::new(precompile_input.gas); +//! let mut precompile_input = precompile_input; +//! +//! let args = IMyPrecompile::myFunctionCall::abi_decode_raw(input) +//! .map_err(|_| PrecompileErrorOrRevert::new_reverted_with_penalty( +//! gas_counter, +//! PRECOMPILE_ABI_DECODE_REVERT_GAS_PENALTY, +//! ERR_EXECUTION_REVERTED, +//! ))?; +//! +//! let output = read( +//! &mut precompile_input.internals, +//! MY_PRECOMPILE_ADDRESS, +//! StorageKey::from(0), +//! &mut gas_counter, +//! hardfork_flags, +//! )?; +//! +//! Ok(PrecompileOutput::new(gas_counter.used(), output)) +//! })() //! }, //! }); //! ``` //! //! ### Step 4: Register the Precompile -//! For stateless precompiles, add to `custom_stateless_precompiles()`: +//! Add a match arm to `ArcPrecompileProvider::create_precompiles_map` in +//! `precompile_provider.rs`: //! ```rust,ignore -//! precompiles.extend([PrecompileWithAddress::from(( -//! MY_PRECOMPILE_ADDRESS, -//! PrecompileFn::from(my_precompile_fn as fn(&[u8], u64) -> PrecompileResult), -//! ))]); -//! ``` -//! -//! For stateful precompiles, add to the `stateful_precompiles` HashMap in `Default::default()`: -//! ```rust,ignore -//! stateful_precompiles.insert( -//! MY_PRECOMPILE_ADDRESS, -//! run_my_precompile as StatefulPrecompileFn, -//! ); +//! MY_PRECOMPILE_ADDRESS => Some(DynPrecompile::new_stateful( +//! PrecompileId::Custom("MY_PRECOMPILE".into()), +//! move |input| run_my_precompile(input, hardfork_flags), +//! )), //! ``` //! //! ## Gas Accounting //! -//! Both macros handle gas accounting automatically: -//! - Stateless precompiles consume all provided gas -//! - Stateful precompiles track gas usage through storage operations -//! - Out-of-gas errors are handled gracefully +//! The `precompile!` macro does not track gas on its own — each arm constructs a `Gas` +//! counter from `precompile_input.gas` and threads `&mut gas_counter` through the helpers +//! (`read`, `write`, `emit_event`, `balance_incr`, …). Helpers mutate the counter in place +//! and return `PrecompileErrorOrRevert::Error(OutOfGas)` when the remaining gas is +//! insufficient. The macro adds `PRECOMPILE_ABI_DECODE_REVERT_GAS_PENALTY` when the +//! selector is unknown or the input is shorter than 4 bytes; arms should use the same +//! penalty when ABI decoding fails. //! //! ## Storage Operations //! -//! The `read` and `write` helper functions provide storage access: -//! - `read`: Costs 2,100 gas, returns stored value -//! - `write`: Costs 41,000 gas (21,000 base + 20,000 SSTORE) +//! `read` and `write` take a mutable borrow of `precompile_input.internals`, the gas +//! counter, and the active hardfork flags. `read` returns the stored value as +//! big-endian `Bytes`; `write` returns `()`: //! -//! When chaining operations, pass the gas counter between calls: //! ```rust,ignore -//! let (output, gas_counter) = read(context, address, key, gas_limit)?; -//! write(context, address, key, &new_value, gas_counter) +//! let output = read( +//! &mut precompile_input.internals, +//! address, +//! key, +//! &mut gas_counter, +//! hardfork_flags, +//! )?; +//! let current = U256::from_be_slice(&output); +//! +//! write( +//! &mut precompile_input.internals, +//! address, +//! 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+. pub mod helpers; mod macros; diff --git a/crates/precompiles/src/macros.rs b/crates/precompiles/src/macros.rs index a2729ae..7455338 100644 --- a/crates/precompiles/src/macros.rs +++ b/crates/precompiles/src/macros.rs @@ -14,42 +14,85 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// Macro for creating stateful precompiles with automatic ABI decoding and gas accounting. +/// Macro for creating stateful precompiles with function-selector dispatch and revert +/// conversion. Gas accounting and ABI decoding are the arm body's responsibility. /// /// # Syntax /// ```rust,ignore -/// stateful!(function_name, context, inputs, gas_limit; { -/// Interface::functionCall => |decoded_args| { -/// // Your implementation here -/// // Must return Result<(PrecompileOutput, u64), PrecompileError> +/// precompile!(fn_name, precompile_input, hardfork_flags; { +/// Interface::functionCall => |calldata_bytes| { +/// // Must evaluate to Result. +/// // `calldata_bytes` is the input after the 4-byte selector. /// }, /// // Additional functions... /// }); /// ``` /// +/// 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_ABI_DECODE_REVERT_GAS_PENALTY` and returns a revert. +/// /// # Example /// ```rust,ignore -/// stateful!(run_counter_precompile, context, inputs, gas_limit; { -/// ICounter::incrementCall => |_call| { -/// let (current_output, gas_counter) = read(context, ADDRESS, KEY, gas_limit)?; -/// let current = U256::from_be_slice(¤t_output.bytes); -/// let new_value = current + U256::from(1); -/// write(context, ADDRESS, KEY, &new_value.to_be_bytes_vec(), gas_counter) +/// precompile!(run_counter_precompile, precompile_input, hardfork_flags; { +/// ICounter::incrementCall => |_input| { +/// (|| -> Result { +/// let mut gas_counter = Gas::new(precompile_input.gas); +/// let mut precompile_input = precompile_input; +/// +/// let output = read( +/// &mut precompile_input.internals, +/// ADDRESS, +/// KEY, +/// &mut gas_counter, +/// hardfork_flags, +/// )?; +/// let new_value = U256::from_be_slice(&output) + U256::from(1); +/// +/// write( +/// &mut precompile_input.internals, +/// ADDRESS, +/// KEY, +/// &new_value.to_be_bytes_vec(), +/// &mut gas_counter, +/// hardfork_flags, +/// )?; +/// +/// Ok(PrecompileOutput::new(gas_counter.used(), new_value.abi_encode().into())) +/// })() /// }, -/// ICounter::getCountCall => |_call| { -/// read(context, ADDRESS, KEY, gas_limit) +/// ICounter::getCountCall => |_input| { +/// (|| -> Result { +/// let mut gas_counter = Gas::new(precompile_input.gas); +/// let mut precompile_input = precompile_input; +/// +/// let output = read( +/// &mut precompile_input.internals, +/// ADDRESS, +/// KEY, +/// &mut gas_counter, +/// hardfork_flags, +/// )?; +/// +/// Ok(PrecompileOutput::new(gas_counter.used(), output)) +/// })() /// }, /// }); /// ``` /// /// The macro handles: /// - Function selector matching -/// - ABI decoding of inputs -/// - Gas accounting -/// - Error conversion -/// - Output formatting +/// - Fallback revert (with gas penalty) for unknown selectors or truncated input +/// - Conversion of `PrecompileErrorOrRevert` into the final `Result` +/// +/// ABI decoding, gas accounting, and output encoding remain the arm body's job; call +/// `<$fn_call>::abi_decode_raw` on the supplied calldata bytes when you need the +/// decoded arguments. #[macro_export] -macro_rules! stateful { +macro_rules! precompile { ($fn_name:ident, $precompile_input:ident, $hardfork_flags:ident; { $( $fn_call:path => |$arg:ident| $body:expr @@ -92,66 +135,3 @@ macro_rules! stateful { } }; } - -/// Macro for creating stateless precompiles with automatic ABI encoding/decoding. -/// -/// # Syntax -/// ```rust,ignore -/// stateless!(function_name, input, gas_limit; { -/// Interface::functionCall => |decoded_call| { -/// // Your implementation here -/// // Must return a value that implements SolValue -/// }, -/// // Additional functions... -/// }); -/// ``` -/// -/// # Example -/// ```rust,ignore -/// stateless!(math_precompile_fn, input, gas_limit; { -/// IMath::addCall => |call| { -/// call.a + call.b -/// }, -/// IMath::multiplyCall => |call| { -/// call.a * call.b -/// }, -/// }); -/// ``` -/// -/// The macro handles: -/// - Function selector matching -/// - ABI decoding of inputs -/// - ABI encoding of outputs -/// - Error handling -/// - Gas consumption (uses all provided gas) -#[macro_export] -macro_rules! stateless { - ($fn_name:ident, $input:ident, $gas_limit:ident; { - $( - $fn_call:path => |$call:ident| $handler:expr - ),* $(,)? - }) => { - pub(crate) fn $fn_name($input: &[u8], $gas_limit: u64) -> PrecompileResult { - if $input.len() < 4 { - return Err(PrecompileError::Other("Input too short".into())); - } - - let selector = &$input[0..4]; - - let output = match selector { - $( - sel if sel == <$fn_call>::SELECTOR => { - let $call = <$fn_call>::abi_decode($input).map_err(|e| { - PrecompileError::Other(format!("ABI decode error: {e:?}").into()) - })?; - let result = $handler; - result.abi_encode() - } - )* - _ => return Err(PrecompileError::Other("Invalid selector".into())), - }; - - Ok(PrecompileOutput::new($gas_limit, output.into())) - } - }; -} diff --git a/crates/precompiles/src/native_coin_authority.rs b/crates/precompiles/src/native_coin_authority.rs index d470e1e..b828164 100644 --- a/crates/precompiles/src/native_coin_authority.rs +++ b/crates/precompiles/src/native_coin_authority.rs @@ -27,7 +27,7 @@ use crate::helpers::{ PRECOMPILE_SSTORE_GAS_COST, }; use crate::native_coin_control::{compute_is_blocklisted_storage_slot, UNBLOCKLISTED_STATUS}; -use crate::stateful; +use crate::precompile; use crate::NATIVE_COIN_CONTROL_ADDRESS; use alloy_evm::EvmInternals; use alloy_primitives::{address, Address, StorageKey, U256}; @@ -196,7 +196,7 @@ fn is_blocklisted( Ok(!U256::from_be_slice(&storage_output).eq(&UNBLOCKLISTED_STATUS)) } -stateful!(run_native_coin_authority, precompile_input, hardfork_flags; { +precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { INativeCoinAuthority::mintCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); @@ -243,7 +243,7 @@ stateful!(run_native_coin_authority, precompile_input, hardfork_flags; { check_delegatecall( NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, - &mut gas_counter, + &gas_counter, )?; // Reject minting to zero address (Zero5+) @@ -288,8 +288,7 @@ stateful!(run_native_coin_authority, precompile_input, hardfork_flags; { )?; // Update account balance - balance_incr(&mut precompile_input.internals, args.to, args.amount, &mut gas_counter, - hardfork_flags.is_active(ArcHardfork::Zero5))?; + balance_incr(&mut precompile_input.internals, args.to, args.amount, &mut gas_counter, hardfork_flags)?; // Emit event: ERC-20 Transfer(0x0, to) under Zero5, NativeCoinMinted otherwise. // Address::ZERO as `from` follows the ERC-20 convention for minting. This is @@ -353,7 +352,7 @@ stateful!(run_native_coin_authority, precompile_input, hardfork_flags; { check_delegatecall( NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, - &mut gas_counter, + &gas_counter, )?; // Reject burning from zero address (Zero5+) @@ -372,7 +371,7 @@ stateful!(run_native_coin_authority, precompile_input, hardfork_flags; { } // Check balance and burn tokens - balance_decr(&mut precompile_input.internals, args.from, args.amount, &mut gas_counter)?; + balance_decr(&mut precompile_input.internals, args.from, args.amount, &mut gas_counter, hardfork_flags)?; // Adjust total supply let total_supply_output = read( @@ -465,7 +464,7 @@ stateful!(run_native_coin_authority, precompile_input, hardfork_flags; { check_delegatecall( NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, - &mut gas_counter, + &gas_counter, )?; // Reject transfers involving zero address (Zero5+) @@ -491,8 +490,7 @@ stateful!(run_native_coin_authority, precompile_input, hardfork_flags; { // 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, false, - hardfork_flags.is_active(ArcHardfork::Zero5))?; + transfer(&mut precompile_input.internals, args.from, args.to, args.amount, &mut gas_counter, hardfork_flags)?; // Emit event: ERC-20 Transfer under Zero5, NativeCoinTransferred otherwise. // EIP-7708: self-transfers (from == to) do not emit a log. @@ -523,8 +521,7 @@ stateful!(run_native_coin_authority, precompile_input, hardfork_flags; { let mut gas_counter = Gas::new(precompile_input.gas); let mut precompile_input = precompile_input; - // Validate the input is correct - if !input.is_empty() { + if !hardfork_flags.is_active(ArcHardfork::Zero6) && !input.is_empty() { return Err(PrecompileErrorOrRevert::new_reverted_with_penalty( gas_counter, PRECOMPILE_ABI_DECODE_REVERT_GAS_PENALTY, ERR_EXECUTION_REVERTED)); } @@ -641,6 +638,9 @@ mod tests { 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, @@ -650,6 +650,8 @@ mod tests { 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). @@ -669,6 +671,7 @@ mod tests { 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, @@ -676,6 +679,7 @@ mod tests { 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, @@ -732,8 +736,15 @@ mod tests { ); } - // Resolve expected gas based on hardfork: Zero5 enables EIP-7708 events. - let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero5) { + // 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) @@ -821,7 +832,11 @@ mod tests { /// Returns the appropriate gas limit based on hardfork fn get_gas_limit(tc: &NativeCoinAuthorityTest, hardfork_flags: ArcHardforkFlags) -> u64 { - if hardfork_flags.is_active(ArcHardfork::Zero5) { + 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) @@ -1040,6 +1055,7 @@ mod tests { gas_used: 8681, pre_zero5_gas_used: Some(MINT_GAS_COST), eip7708_gas_used: Some(9056), + zero6_gas_used: Some(9556), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, ..Default::default() @@ -1129,6 +1145,7 @@ mod tests { 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), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, ..Default::default() @@ -1239,6 +1256,7 @@ mod tests { gas_used: 8681, pre_zero5_gas_used: Some(BURN_GAS_COST), eip7708_gas_used: Some(9056), + zero6_gas_used: Some(7056), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, ..Default::default() @@ -1308,6 +1326,8 @@ mod tests { 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), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, ..Default::default() @@ -1323,9 +1343,11 @@ 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), expected_revert_str: None, expected_result: InstructionResult::PrecompileOOG, return_data: None, @@ -1488,6 +1510,7 @@ mod tests { blocklisted_addresses: None, gas_used: 15956, pre_zero5_gas_used: Some(TRANSFER_GAS_COST), + zero6_gas_used: Some(14456), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, ..Default::default() @@ -1513,6 +1536,7 @@ mod tests { pre_zero5_gas_used: Some( PRECOMPILE_SLOAD_GAS_COST * 4 + PRECOMPILE_SSTORE_GAS_COST, ), + zero6_gas_used: Some(7200), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, ..Default::default() @@ -1537,6 +1561,7 @@ mod tests { blocklisted_addresses: None, gas_used: 15956, pre_zero5_gas_used: Some(TRANSFER_GAS_COST), + zero6_gas_used: Some(14456), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, ..Default::default() @@ -1642,6 +1667,7 @@ mod tests { gas_used: 8681, pre_zero5_gas_used: Some(MINT_GAS_COST), eip7708_gas_used: Some(9056), + zero6_gas_used: Some(9556), target_address: NATIVE_COIN_AUTHORITY_ADDRESS, bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, ..Default::default() @@ -1718,6 +1744,12 @@ mod tests { 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; } @@ -2246,17 +2278,25 @@ mod tests { // Zero5 + EIP-7708: self-transfers do not emit a log assert_eq!(logs.len(), 0, "Zero5: self-transfer should not emit a log"); - // Gas accounting: self-transfer still executes the full transfer path - // (balance_decr + balance_incr) to preserve gas invariants across the - // hardfork boundary — only the event emission is suppressed. - // - // Breakdown: - // blocklist(from) cold SLOAD: 2100 - // blocklist(to) warm SLOAD: 100 (same address, slot already warm) - // transfer() fixed: 10000 (2 SLOADs + 2 SSTOREs) - // event: skipped (from == to) - let expected_gas = - 2100 + 100 + 2 * PRECOMPILE_SLOAD_GAS_COST + 2 * PRECOMPILE_SSTORE_GAS_COST; + // 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, @@ -2279,10 +2319,17 @@ mod tests { .encode_log_data(); assert_eq!(log.data, expected_log); - // Gas accounting: auth SLOAD + 2 blocklist SLOADs + transfer + event + // 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, + TRANSFER_GAS_COST - zero6_account_load_delta, "Pre-Zero5: self-transfer gas should match TRANSFER_GAS_COST (flags={hardfork_flags:?})", ); } @@ -2476,4 +2523,91 @@ mod tests { Some(ERR_SELFDESTRUCTED_BALANCE_INCREASED.to_string()) ); } + + 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; + } + + let mut ctx = mock_context(hardfork_flags); + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: NATIVE_COIN_AUTHORITY_ADDRESS, + bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, + known_bytecode: None, + caller: ADDRESS_A, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(total_supply_calldata_with_trailing_bytes()), + 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("result should be Some"); + + assert_eq!( + result.result, + InstructionResult::Revert, + "({hardfork_flags:?}): expected Revert with trailing calldata pre-Zero6", + ); + assert_eq!( + bytes_to_revert_message(result.output.as_ref()).as_deref(), + Some(ERR_EXECUTION_REVERTED), + "({hardfork_flags:?}): expected execution reverted message", + ); + } + } + + #[test] + fn total_supply_accepts_extra_input_with_zero6() { + let mock_initial_supply = U256::from(1_000_000_000); + + for hardfork_flags in ArcHardforkFlags::all_combinations() { + if !hardfork_flags.is_active(ArcHardfork::Zero6) { + continue; + } + + let mut ctx = mock_context(hardfork_flags); + setup_initial_state(&mut ctx, mock_initial_supply); + + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: NATIVE_COIN_AUTHORITY_ADDRESS, + bytecode_address: NATIVE_COIN_AUTHORITY_ADDRESS, + known_bytecode: None, + caller: ADDRESS_A, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(total_supply_calldata_with_trailing_bytes()), + 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("result should be Some"); + + assert_eq!( + result.result, + InstructionResult::Return, + "({hardfork_flags:?}): expected Return with trailing calldata under Zero6", + ); + 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", + ); + } + } } diff --git a/crates/precompiles/src/native_coin_control.rs b/crates/precompiles/src/native_coin_control.rs index b0a6a23..df20442 100644 --- a/crates/precompiles/src/native_coin_control.rs +++ b/crates/precompiles/src/native_coin_control.rs @@ -25,7 +25,7 @@ use crate::helpers::{ NATIVE_FIAT_TOKEN_ADDRESS, PRECOMPILE_ABI_DECODE_REVERT_GAS_PENALTY, PRECOMPILE_SLOAD_GAS_COST, PRECOMPILE_SSTORE_GAS_COST, }; -use crate::stateful; +use crate::precompile; use alloy_evm::EvmInternals; use alloy_primitives::{address, Address, StorageKey, U256}; use alloy_sol_types::{sol, SolCall, SolValue}; @@ -131,7 +131,7 @@ pub fn compute_is_blocklisted_storage_slot(key: Address) -> StorageKey { StorageKey::new(native_coin_control_config::compute_is_blocklisted_storage_slot(key).0) } -stateful!(run_native_coin_control, precompile_input, hardfork_flags; { +precompile!(run_native_coin_control, precompile_input, hardfork_flags; { INativeCoinControl::blocklistCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); @@ -181,7 +181,7 @@ stateful!(run_native_coin_control, precompile_input, hardfork_flags; { check_delegatecall( NATIVE_COIN_CONTROL_ADDRESS, &precompile_input, - &mut gas_counter, + &gas_counter, )?; // Add to blocklist @@ -294,7 +294,7 @@ stateful!(run_native_coin_control, precompile_input, hardfork_flags; { check_delegatecall( NATIVE_COIN_CONTROL_ADDRESS, &precompile_input, - &mut gas_counter, + &gas_counter, )?; // Remove from blocklist diff --git a/crates/precompiles/src/pq.rs b/crates/precompiles/src/pq.rs index 6f12ab5..b25c393 100644 --- a/crates/precompiles/src/pq.rs +++ b/crates/precompiles/src/pq.rs @@ -23,7 +23,7 @@ use crate::helpers::{ record_cost_or_out_of_gas, PrecompileErrorOrRevert, ERR_EXECUTION_REVERTED, PRECOMPILE_ABI_DECODE_REVERT_GAS_PENALTY, }; -use crate::stateful; +use crate::precompile; use alloy_primitives::{address, Address}; use alloy_sol_types::{sol, SolCall, SolValue}; use reth_ethereum::evm::revm::precompile::PrecompileOutput; @@ -35,7 +35,10 @@ pub const PQ_ADDRESS: Address = address!("18000000000000000000000000000000000000 /// Base gas for SLH-DSA-SHA2-128s verification. /// -/// Covers ~1,500 SHA256 operations for FORS/WOTS+/Merkle tree 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. @@ -49,11 +52,11 @@ sol! { interface IPQ { /// Verify an SLH-DSA-SHA2-128s signature /// Gas cost: 230,000 base + 6 per 32-byte word of message (same as KECCAK256) - function verifySlhDsaSha2128s(bytes vk, bytes msg, bytes sig) external returns (bool isValid); + function verifySlhDsaSha2128s(bytes calldata vk, bytes calldata msg, bytes calldata sig) external returns (bool isValid); } } -stateful!(run_pq, precompile_input, hardfork_flags; { +precompile!(run_pq, precompile_input, hardfork_flags; { IPQ::verifySlhDsaSha2128sCall => |input| { (|| -> Result { let _ = hardfork_flags; diff --git a/crates/precompiles/src/precompile_provider.rs b/crates/precompiles/src/precompile_provider.rs index e79f56f..8bcb38d 100644 --- a/crates/precompiles/src/precompile_provider.rs +++ b/crates/precompiles/src/precompile_provider.rs @@ -33,9 +33,8 @@ use revm_primitives::address; /// Custom precompile provider that extends Ethereum's standard precompiles /// with Arc functionality. /// -/// This provider supports both: -/// - **Stateless precompiles**: Added at compile time via `custom_stateless_precompiles()` -/// - **Stateful precompiles**: Managed dynamically via the `stateful_precompiles` HashMap +/// This provider supports: +/// - **Stateful precompiles**: Registered dynamically via `set_precompile_lookup` #[derive(Debug)] pub struct ArcPrecompileProvider; diff --git a/crates/precompiles/src/system_accounting.rs b/crates/precompiles/src/system_accounting.rs index 0e3964a..40266e8 100644 --- a/crates/precompiles/src/system_accounting.rs +++ b/crates/precompiles/src/system_accounting.rs @@ -19,7 +19,7 @@ use crate::helpers::{ PrecompileErrorOrRevert, ERR_EXECUTION_REVERTED, ERR_INVALID_CALLER, PRECOMPILE_ABI_DECODE_REVERT_GAS_PENALTY, PRECOMPILE_SLOAD_GAS_COST, }; -use crate::stateful; +use crate::precompile; use alloy_evm::Evm; use alloy_primitives::B256; use alloy_primitives::{address, keccak256, Address, Bytes, StorageKey}; @@ -51,7 +51,11 @@ const GAS_VALUES_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, ]); -// Number of historical gas values to store in ring buffer +/// Ring buffer capacity for historical gas values. Consensus reads only +/// freshly-written slots (the executor reads the parent slot for EMA smoothing; +/// the assembler reads the current slot just written by `finish()`), so no +/// history depth is required for correctness. The extra capacity exists purely +/// as headroom for external readers (RPC, monitoring) and is otherwise arbitrary. const GAS_VALUES_RING_BUFFER_SIZE: u64 = 64; // Address impersonated by the system caller @@ -67,7 +71,26 @@ sol! { } interface ISystemAccounting { + /// Writes `gasValues` into ring-buffer slot + /// `blockNumber % GAS_VALUES_RING_BUFFER_SIZE`, overwriting whatever + /// the slot previously held. SYSTEM_CALLER-gated; no validation on + /// `blockNumber`, since writes happen once per block from the block + /// executor. function storeGasValues(uint64 blockNumber, GasValues calldata gasValues) external returns (bool); + + /// Returns ring-buffer slot `blockNumber % GAS_VALUES_RING_BUFFER_SIZE` + /// as-is, without any freshness check. If `blockNumber` has been + /// rotated out (more than `GAS_VALUES_RING_BUFFER_SIZE - 1` behind the + /// latest written block) or is in the future, the slot holds the last + /// block that mapped to it, i.e. a different block's values. Slots + /// that have never been written (possible only early in the chain's + /// life, before every slot has been reached once) read as zero. + /// Callers needing freshness must cross-check against their own view + /// of the chain tip. Consensus does not depend on freshness: the + /// executor reads only the parent slot for EMA smoothing, which was + /// written by the previous block's `finish()` (or reads as zero at + /// genesis, the correct EMA baseline), and the block assembler reads + /// only the current slot just written by the same block's `finish()`. function getGasValues(uint64 blockNumber) external view returns (GasValues calldata gasValue); } } @@ -83,6 +106,12 @@ sol! { /// - p is the mapping slot position (GAS_VALUES_STORAGE_KEY) /// - h left-pads the key to 32 bytes /// - . is concatenation +/// +/// `block_number` is reduced mod `GAS_VALUES_RING_BUFFER_SIZE` before hashing, +/// so any two block numbers that differ by a multiple of the ring buffer size +/// collide on the same slot. The mapping carries no identity of the block that +/// last wrote the slot — callers who need that identity must track it +/// out-of-band. pub fn compute_gas_values_storage_slot(block_number: u64) -> StorageKey { // Map block number into ring buffer let key_value = block_number % GAS_VALUES_RING_BUFFER_SIZE; @@ -102,7 +131,7 @@ pub fn compute_gas_values_storage_slot(block_number: u64) -> StorageKey { StorageKey::new(keccak256(data).0) } -stateful!(run_system_accounting, precompile_input, hardfork_flags; { +precompile!(run_system_accounting, precompile_input, hardfork_flags; { ISystemAccounting::storeGasValuesCall => |input| { (|| -> Result { let mut gas_counter = Gas::new(precompile_input.gas); @@ -134,7 +163,7 @@ stateful!(run_system_accounting, precompile_input, hardfork_flags; { check_delegatecall( SYSTEM_ACCOUNTING_ADDRESS, &precompile_input, - &mut gas_counter, + &gas_counter, )?; // Update storage diff --git a/crates/quake/README.md b/crates/quake/README.md index 95e248f..1f080c1 100644 --- a/crates/quake/README.md +++ b/crates/quake/README.md @@ -740,7 +740,7 @@ quake generate -o target/manifests **Nightly CI** -A [nightly workflow](../../.github/workflows/nightly-random-manifests.yml) runs daily at 3 AM UTC and can be triggered: +A nightly workflow runs daily at 3 AM UTC and can be triggered: - **Scheduled/PR runs**: Use seed `42` for reproducibility. - **Manual dispatch** (`workflow_dispatch`): Optionally specify a base seed (must be a non-negative integer; leave blank to use `42`) and a per-job count (positive integer ≤ 50; leave blank to use `3`). @@ -830,7 +830,7 @@ schema depends on the CL image version (`image_cl`): deprecation. Quake detects which schema to use by parsing the `image_cl` tag; `latest`, -missing tags, and unparseable tags are treated as Modern. The two formats +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 @@ -838,6 +838,21 @@ Upgrading a running testnet across the legacy/modern boundary with with no CLI flags. For upgrade scenarios, start the testnet on a Modern version. +#### Matching Flags to the Target Image Version + +Upgrade scenarios can pin an older `arc_consensus` image tag (e.g. +`v0.6.0`). Quake derives CLI flags from the `StartCmd` definition +compiled into its own binary, which may have gained, renamed, or removed +flags since that image shipped. Before handing the flags to the +container, Quake rewrites them to match the target version, i.e., older +images receive a compatible subset, and `"latest"`, missing, or +unparsable tags pass through unchanged. + +If pinning an older image fails with `unexpected argument`, that version +likely needs a new compatible entry. See `apply_version_compat` in +[`src/cli_version.rs`](src/cli_version.rs) for the rustdoc describing +how to add one. + 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.`. diff --git a/crates/quake/scenarios/examples/arc-node.toml b/crates/quake/scenarios/examples/arc-node.toml index 707ace1..11fefbc 100644 --- a/crates/quake/scenarios/examples/arc-node.toml +++ b/crates/quake/scenarios/examples/arc-node.toml @@ -40,13 +40,14 @@ el.config.tx_propagation_policy = "Trusted" [nodes.validator3] [nodes.validator4] -# full-p2p is the only externally-exposed node, enable MEV protection -# and restrict RPC surface. arc-node forwards transactions here via --rpc.forwarder. +# full-p2p is the only externally-exposed node; narrow the RPC surface to +# the safe set for MEV protection. Pending-tx hiding is the default, so no +# explicit flag is needed. arc-node forwards transactions here via +# --rpc.forwarder. [nodes.full-p2p] el.config.tx_propagation_policy = "Trusted" el.config.http.api = ["eth", "net", "web3"] el.config.ws.api = ["eth", "net", "web3"] -el.config.arc.hide_pending_txs = true # libp2p sync-only full node — --full profile (snapshot source + follow target) [nodes.snapshot] diff --git a/crates/quake/scenarios/examples/testnet-small-default.toml b/crates/quake/scenarios/examples/testnet-small-default.toml index 58139af..3f994bd 100644 --- a/crates/quake/scenarios/examples/testnet-small-default.toml +++ b/crates/quake/scenarios/examples/testnet-small-default.toml @@ -112,7 +112,6 @@ el.config.disable_discovery = true el.config.http.api = ["eth", "net", "web3"] el.config.ws.api = ["eth", "net", "web3"] el.config.prune.preset = "full" -el.config.arc.hide_pending_txs = true # --- Snapshot node (full profile, snapshot source) --- diff --git a/crates/quake/scenarios/examples/testnet-small.toml b/crates/quake/scenarios/examples/testnet-small.toml index bece671..fe499b2 100644 --- a/crates/quake/scenarios/examples/testnet-small.toml +++ b/crates/quake/scenarios/examples/testnet-small.toml @@ -124,7 +124,6 @@ el.config.disable_discovery = true el.config.http.api = ["eth", "net", "web3"] el.config.ws.api = ["eth", "net", "web3"] el.config.prune.preset = "full" -el.config.arc.hide_pending_txs = true # --- Snapshot node (full profile, snapshot source) --- diff --git a/crates/quake/scenarios/localdev.toml b/crates/quake/scenarios/localdev.toml index b9f5f94..a997c75 100644 --- a/crates/quake/scenarios/localdev.toml +++ b/crates/quake/scenarios/localdev.toml @@ -2,21 +2,24 @@ # enabled by default. cl.config.execution_persistence_backpressure = true -# All 5 validators use 0x65E0a200006D4FF91bD59F9694220dafc49dbBC1 (LOCALDEV_FEE_RECIPIENT) as -# cl_suggested_fee_recipient so tests can hard-code a single known beneficiary address. +# Each validator uses a distinct, visually-distinctive fee recipient so tests +# can tell which validator proposed a given block. Paired with genesis where +# `ProtocolConfig.rewardBeneficiary = 0x0`, this causes the EL to honour each +# validator's `--suggested-fee-recipient`. See `LOCALDEV_FEE_RECIPIENTS` in +# `tests/helpers/networks/localdev.ts` (which mirrors these addresses index-for-index). [nodes.validator1] -cl_suggested_fee_recipient = "0x65E0a200006D4FF91bD59F9694220dafc49dbBC1" +cl_suggested_fee_recipient = "0x1111111111111111111111111111111111111111" [nodes.validator2] -cl_suggested_fee_recipient = "0x65E0a200006D4FF91bD59F9694220dafc49dbBC1" +cl_suggested_fee_recipient = "0x2222222222222222222222222222222222222222" [nodes.validator3] -cl_suggested_fee_recipient = "0x65E0a200006D4FF91bD59F9694220dafc49dbBC1" +cl_suggested_fee_recipient = "0x3333333333333333333333333333333333333333" [nodes.validator4] -cl_suggested_fee_recipient = "0x65E0a200006D4FF91bD59F9694220dafc49dbBC1" +cl_suggested_fee_recipient = "0x4444444444444444444444444444444444444444" [nodes.validator5] -cl_suggested_fee_recipient = "0x65E0a200006D4FF91bD59F9694220dafc49dbBC1" +cl_suggested_fee_recipient = "0x5555555555555555555555555555555555555555" [nodes.full1] diff --git a/crates/quake/src/cli_version.rs b/crates/quake/src/cli_version.rs new file mode 100644 index 0000000..695e97b --- /dev/null +++ b/crates/quake/src/cli_version.rs @@ -0,0 +1,408 @@ +// 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. + +//! Version checks and compatibility shims 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. +//! +//! * [`apply_version_compat`] rewrites the CLI flag list produced by the +//! current Quake binary so it matches the target image's `StartCmd` +//! schema (flags added, removed, or renamed between the target release +//! and the `StartCmd` definition compiled into this build). This layer +//! 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. +////////////////////////////////////////////////////////////////// + +/// 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, +} + +/// 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, + } +} + +/// 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". +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); + if version_str == "latest" { + return None; + } + let version_str = version_str.strip_prefix('v').unwrap_or(version_str); + let parts: Vec<&str> = version_str.split('.').collect(); + if parts.len() < 3 { + return None; + } + let major = parts[0].parse::().ok()?; + let minor = parts[1].parse::().ok()?; + let patch_str = parts[2].split('-').next().unwrap_or(parts[2]); + let patch = patch_str.parse::().ok()?; + Some((major, minor, patch)) +} + +/// Rewrite the CLI flag list produced by this Quake build so it matches the +/// `StartCmd` schema of the target `arc-node-consensus` image, and return +/// the rewritten list. +/// +/// The input is what `StartCmd::to_cli_flags()` emits for the current Quake +/// binary — it reflects the `StartCmd` definition as of the commit Quake was +/// built from, which may have gained, renamed, or removed flags relative to +/// older released images. Each `if` block below handles one target version's +/// deviation from the current build; blocks are ordered oldest-to-newest. +/// `None`, `"latest"`, and unparsable tags short-circuit the whole pass and +/// return the input unchanged. +/// +/// When a change to `StartCmd`'s CLI schema lands and any pinned image +/// in scenario rotation — the `image_cl` / `image_cl_upgrade` fields in +/// every TOML under `crates/quake/scenarios/` — would fail to parse the +/// resulting flag list, add a compat block in the same change. +/// Concretely: +/// +/// * Adding a flag: drop it for every pinned tag released before the +/// flag existed. +/// * Renaming a flag: rewrite the new name back to the old one for every +/// pinned tag released before the rename. +/// * Removing a flag while keeping the `StartCmd` field as a deprecated +/// stub: drop the flag for every pinned tag released after the +/// removal. +/// +/// When all pinned tags are newer than the change (or every scenario +/// uses `"latest"`), no block is needed. Anchor each version bound on +/// the *last released tag that demonstrably lacks the change*, so the +/// block stays correct for every later release without another edit. +/// Any pure function of the flag list and the target version belongs +/// here — drop, rewrite, collapse two into one, split one into two, etc. +/// +/// A `StartCmd` field deleted outright with no deprecated stub cannot +/// be expressed here: `to_cli_flags()` has nothing to emit, so no +/// post-processing can synthesize the flag. Keep such fields as +/// `#[arg(hide = true)]` in the CL crate until every pinned image stops +/// needing them. +pub(crate) fn apply_version_compat(mut flags: Vec, image_tag: Option<&str>) -> Vec { + let Some(version) = parse_image_semver(image_tag) else { + return flags; + }; + + // Apply V0_5_0 compat. + if version <= V0_5_0 { + const FLAGS_ADDED_AFTER_V0_5_0: &[&str] = &[ + "--log-level", + "--log-format", + "--p2p.persistent-peers-only", + "--gossipsub.explicit-peering", + "--gossipsub.mesh-prioritization", + "--gossipsub.load", + "--execution-persistence-backpressure", + "--execution-persistence-backpressure-threshold", + "--execution-ws-endpoint", + "--full", + "--minimal", + "--pprof.heap-prof", + ]; + flags.retain(|f| { + !FLAGS_ADDED_AFTER_V0_5_0 + .iter() + .any(|added| flag_matches(f, added)) + }); + + // Rewrite renamed flags, preserving any `=value` suffix. + for f in &mut flags { + if let Some(rest) = f.strip_prefix("--prune.certificates.distance") { + if rest.starts_with('=') { + *f = format!("--pruning.block-interval{rest}"); + } + } else if let Some(rest) = f.strip_prefix("--prune.certificates.before") { + if rest.starts_with('=') { + *f = format!("--pruning.min-height{rest}"); + } + } + } + } + + // Apply V0_6_0 compat. + if version <= V0_6_0 { + flags.retain(|f| !flag_matches(f, "--validator")); + } + + flags +} + +/// `to_cli_flags` emits either `"--foo"` (booleans) or `"--foo="` +/// (values, including comma-joined lists like `"--foo=a,b,c"`). Both forms +/// match a bare `"--foo"` entry. +fn flag_matches(emitted: &str, flag_name: &str) -> bool { + emitted == flag_name + || emitted + .strip_prefix(flag_name) + .is_some_and(|rest| rest.starts_with('=')) +} + +#[cfg(test)] +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)); + } + + #[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"))); + } + + /// Run `apply_version_compat` over `input` for the given `image_tag` and + /// assert the rewritten list equals `expected`. + #[track_caller] + fn assert_compat(image_tag: &str, input: &[&str], expected: &[&str]) { + let input: Vec = input.iter().map(|s| s.to_string()).collect(); + let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); + let result = apply_version_compat(input, Some(image_tag)); + assert_eq!(result, expected, "compat mismatch for tag {image_tag}"); + } + + fn compat_flags(image_tag: Option<&str>) -> Vec { + let flags = vec![ + "--moniker=val-1".to_string(), + "--validator".to_string(), + "--suggested-fee-recipient=0x1".to_string(), + ]; + apply_version_compat(flags, image_tag) + } + + #[test] + fn apply_version_compat_passes_flags_through_for_missing_latest_and_unparsable_tags() { + for tag in [ + None, + Some("arc_consensus:latest"), + Some("latest"), + Some("arc_consensus:abc123"), + Some("main"), + ] { + let result = compat_flags(tag); + assert!( + result.iter().any(|f| f == "--validator"), + "--validator should survive compat pass for tag {tag:?}, got {result:?}" + ); + } + } + + #[test] + fn apply_version_compat_drops_validator_for_images_at_or_below_v0_6_0() { + for tag in [ + "arc_consensus:v0.6.0", + "arc_consensus:v0.5.9", + "arc_consensus:v0.4.0", + "v0.6.0", + "0.6.0", + "v0.6.0-rc1", + ] { + let result = compat_flags(Some(tag)); + assert!( + !result.iter().any(|f| f == "--validator"), + "--validator should be dropped for tag {tag:?}, got {result:?}" + ); + assert!(result.iter().any(|f| f == "--moniker=val-1")); + assert!(result.iter().any(|f| f == "--suggested-fee-recipient=0x1")); + } + } + + #[test] + fn apply_version_compat_keeps_validator_for_images_strictly_newer_than_v0_6_0() { + for tag in [ + "arc_consensus:v0.6.1", + "arc_consensus:v0.6.2", + "arc_consensus:v0.7.0", + "arc_consensus:v1.0.0", + "v0.6.1", + "0.6.1", + "v0.6.1-rc1", + "v0.7.0-beta", + ] { + let result = compat_flags(Some(tag)); + assert!( + result.iter().any(|f| f == "--validator"), + "--validator should survive compat pass for tag {tag:?}, got {result:?}" + ); + } + } + + #[test] + fn flag_matches_handles_boolean_and_value_forms() { + assert!(flag_matches("--validator", "--validator")); + assert!(flag_matches("--moniker=val-1", "--moniker")); + assert!(flag_matches( + "--p2p.persistent-peers=a,b,c", + "--p2p.persistent-peers" + )); + assert!(!flag_matches("--validator-set", "--validator")); + assert!(!flag_matches("--moniker=validator", "--validator")); + assert!(!flag_matches("--validator.something", "--validator")); + } + + #[test] + fn apply_version_compat_drops_v0_5_era_added_flags_for_v0_5_0() { + assert_compat( + "arc_consensus:v0.5.0", + &[ + "--moniker=val-1", + "--log-level=info", + "--log-format=json", + "--p2p.persistent-peers-only", + "--gossipsub.explicit-peering", + "--gossipsub.mesh-prioritization", + "--gossipsub.load=high", + "--execution-persistence-backpressure", + "--execution-persistence-backpressure-threshold=5", + "--execution-ws-endpoint=ws://el:8546", + "--full", + "--minimal", + "--pprof.heap-prof", + ], + &["--moniker=val-1"], + ); + } + + #[test] + fn apply_version_compat_keeps_v0_5_era_added_flags_for_v0_5_1_and_later() { + for tag in ["v0.5.1", "v0.6.0", "v0.7.0", "v0.5.1-rc1"] { + assert_compat( + tag, + &["--log-level=info", "--gossipsub.load=high", "--full"], + &["--log-level=info", "--gossipsub.load=high", "--full"], + ); + } + } + + #[test] + fn apply_version_compat_renames_pruning_flags_for_v0_5_0() { + assert_compat( + "arc_consensus:v0.5.0", + &[ + "--moniker=val-1", + "--prune.certificates.distance=237600", + "--prune.certificates.before=100", + ], + &[ + "--moniker=val-1", + "--pruning.block-interval=237600", + "--pruning.min-height=100", + ], + ); + } + + #[test] + fn apply_version_compat_keeps_new_pruning_names_for_v0_5_1_and_later() { + for tag in ["v0.5.1", "v0.6.0", "v0.7.0"] { + assert_compat( + tag, + &[ + "--prune.certificates.distance=237600", + "--prune.certificates.before=100", + ], + &[ + "--prune.certificates.distance=237600", + "--prune.certificates.before=100", + ], + ); + } + } +} diff --git a/crates/quake/src/infra/ssm.rs b/crates/quake/src/infra/ssm.rs index 5e6b9ae..33bbdd6 100644 --- a/crates/quake/src/infra/ssm.rs +++ b/crates/quake/src/infra/ssm.rs @@ -141,7 +141,15 @@ impl Ssm { match status { TunnelStatus::Usable => continue, TunnelStatus::Conflict => { - conflicting_sessions.push(session.label()); + // Before giving up, check whether the port is held by a stale + // session-manager-plugin from a previous run and kill it if so. + if kill_stale_ssm_on_port(session.local_port) + && !self.backend.is_local_port_listening(session.local_port) + { + sessions_to_start.push(session); + } else { + conflicting_sessions.push(session.label()); + } } TunnelStatus::StaleAws => { stale_sessions.extend(aws_sessions_for_tunnel.into_iter().cloned()); @@ -551,6 +559,55 @@ fn is_local_port_listening(local_port: u16) -> bool { TcpStream::connect_timeout(&addr, Duration::from_millis(50)).is_ok() } +/// If the process holding `port` is a stale `session-manager-plugin` (an SSM +/// tunnel left over from a previous Quake run), kill it and return `true`. +/// Returns `false` when the port is held by an unrelated process. +fn kill_stale_ssm_on_port(port: u16) -> bool { + // lsof -t prints just the PID(s); -sTCP:LISTEN restricts to listening sockets. + let output = std::process::Command::new("lsof") + .args(["-i", &format!("TCP:{port}"), "-sTCP:LISTEN", "-t"]) + .output(); + + let pid_str = match output { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(), + _ => return false, + }; + let pid: u32 = match pid_str.lines().next().and_then(|s| s.trim().parse().ok()) { + Some(p) => p, + None => return false, + }; + + // Check the process name — only kill the AWS SSM plugin, nothing else. + let name_output = std::process::Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "comm="]) + .output(); + let proc_name = match name_output { + Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(), + Err(_) => return false, + }; + + if !proc_name.contains("session-manager") { + return false; + } + + warn!( + pid, + port, "Killing stale session-manager-plugin process on port {port}" + ); + let killed = std::process::Command::new("kill") + .arg(pid.to_string()) + .status(); + if killed.as_ref().map_or(true, |s| !s.success()) { + debug!( + pid, + port, "Failed to kill stale session-manager-plugin; port may still be held" + ); + } + // Give the OS a moment to release the port. + std::thread::sleep(Duration::from_millis(200)); + true +} + #[cfg(test)] impl Ssm { /// Builds an [`Ssm`] with a mock backend for tests. diff --git a/crates/quake/src/main.rs b/crates/quake/src/main.rs index 3468767..3648a2b 100644 --- a/crates/quake/src/main.rs +++ b/crates/quake/src/main.rs @@ -43,6 +43,7 @@ use testnet::{Testnet, TestnetError}; mod build; mod clean; +mod cli_version; mod genesis; mod info; mod infra; diff --git a/crates/quake/src/manifest.rs b/crates/quake/src/manifest.rs index e3530c3..57399f1 100644 --- a/crates/quake/src/manifest.rs +++ b/crates/quake/src/manifest.rs @@ -307,11 +307,10 @@ pub struct ElArcBuilderConfig { #[serde(deny_unknown_fields, default)] pub struct ElArcConfig { pub denylist: ElArcDenylistConfig, - /// When true, passes `--arc.hide-pending-txs` which enables the - /// pending-tx subscription filter and pending-block interception - /// middleware. Set to true on externally-exposed nodes for MEV protection. + /// 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 hide_pending_txs: bool, + pub expose_pending_txs: bool, pub builder: ElArcBuilderConfig, } @@ -1982,6 +1981,60 @@ mod tests { .contains(&"--arc.builder.wait-for-payload=true".to_string())); } + #[test] + fn test_arc_expose_pending_txs_parses_and_emits_flag() { + let str = r#" + [nodes.validator1] + el.config.arc.expose_pending_txs = true + "#; + let manifest = Manifest::from_string(str).unwrap(); + + assert!( + manifest.nodes["validator1"] + .el_config + .arc + .expose_pending_txs + ); + assert!(manifest.nodes["validator1"] + .el_cli_flags() + .unwrap() + .contains(&"--arc.expose-pending-txs".to_string())); + } + + #[test] + fn test_arc_expose_pending_txs_omitted_when_unset() { + let str = r#" + [nodes.validator1] + "#; + let manifest = Manifest::from_string(str).unwrap(); + + assert!( + !manifest.nodes["validator1"] + .el_config + .arc + .expose_pending_txs + ); + assert!(!manifest.nodes["validator1"] + .el_cli_flags() + .unwrap() + .iter() + .any(|f| f.contains("expose-pending-txs"))); + } + + #[test] + fn test_arc_hide_pending_txs_stale_field_rejected() { + let str = r#" + [nodes.validator1] + el.config.arc.hide_pending_txs = true + "#; + let err = Manifest::from_string(str).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("hide_pending_txs") || msg.contains("unknown field"), + "stale field should trigger deny_unknown_fields error, got: {msg}" + ); + } + #[test] fn test_arc_builder_wait_for_payload_omitted_when_unset() { let str = r#" diff --git a/crates/quake/src/manifest/raw.rs b/crates/quake/src/manifest/raw.rs index 7e2a013..68bed75 100644 --- a/crates/quake/src/manifest/raw.rs +++ b/crates/quake/src/manifest/raw.rs @@ -21,13 +21,13 @@ use indexmap::{IndexMap, IndexSet}; use serde::{Deserialize, Serialize}; use tracing::warn; +use crate::cli_version::supports_cli_flags; use crate::manifest::subnets::Subnets; use crate::manifest::{ default_subnet_singleton, ClGossipSubConfig, ClPruningPreset, DockerImages, ElConfigOverride, EngineApiConnection, Manifest, Node, NodeClConfig, NodeType, RemoteKeyId, }; use crate::node::SubnetName; -use crate::setup::supports_cli_flags; use crate::util::merge_toml_values; /// Node name prefix that indicates a validator node. diff --git a/crates/quake/src/setup.rs b/crates/quake/src/setup.rs index ec3a06c..e577d77 100644 --- a/crates/quake/src/setup.rs +++ b/crates/quake/src/setup.rs @@ -39,6 +39,7 @@ use serde::Serialize; use tracing::{debug, warn}; use url::Url; +use crate::cli_version::{apply_version_compat, supports_cli_flags}; use crate::infra::InfraType; use crate::manifest::{self, Subnets}; use crate::node::{CidrBlock, NodeMetadata, NodeName, SubnetName, RETH_HTTP_BASE_PORT}; @@ -52,8 +53,10 @@ const APP_METRICS_DEFAULT_PORT: usize = 29000; const APP_RPC_DEFAULT_PORT: usize = 31000; const REMOTE_SIGNER_PROXY_PORT: usize = 10340; -/// Placeholder recipient for validators that don't set `cl_suggested_fee_recipient`; -/// matches `LOCALDEV_FEE_RECIPIENT` in `tests/helpers/networks/localdev.ts`. +/// Fallback recipient when a validator scenario doesn't set `cl_suggested_fee_recipient`. +/// Matches `LOCALDEV_FEE_RECIPIENT` in `tests/helpers/networks/localdev.ts`. Used by +/// scenarios like `localdev-remote-signer.toml`; `localdev.toml` sets per-validator +/// recipients explicitly. const QUAKE_DEFAULT_FEE_RECIPIENT: Address = address!("0x65E0a200006D4FF91bD59F9694220dafc49dbBC1"); /// Compile system contracts and bindings @@ -692,63 +695,6 @@ fn save_config(path: &Path, config: &Config) -> Result<()> { Ok(()) } -/// The minimum version that supports CLI flags instead of config.toml -const MIN_CLI_FLAGS_VERSION: (u64, u64, u64) = (0, 5, 0); - -/// 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, -} - -/// 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; - } - let version_str = version_str.strip_prefix('v').unwrap_or(version_str); - let parts: Vec<&str> = version_str.split('.').collect(); - if parts.len() < 3 { - return CliVersionCheck::Assumed; - } - let Ok(major) = parts[0].parse::() else { - return CliVersionCheck::Assumed; - }; - let Ok(minor) = parts[1].parse::() else { - return CliVersionCheck::Assumed; - }; - let patch_str = parts[2].split('-').next().unwrap_or(parts[2]); - let Ok(patch) = patch_str.parse::() else { - return CliVersionCheck::Assumed; - }; - if (major, minor, patch) >= MIN_CLI_FLAGS_VERSION { - CliVersionCheck::SupportsCli - } else { - CliVersionCheck::RequiresConfigToml - } -} - -/// 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 unparseable 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. @@ -815,7 +761,8 @@ pub(crate) fn generate_consensus_cli_flags( // `--validator` requires a non-zero `--suggested-fee-recipient`. When // validator scenarios omit `cl_suggested_fee_recipient`, fall back to - // the localdev placeholder so consensus can still start. + // 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) @@ -852,7 +799,7 @@ pub(crate) fn generate_consensus_cli_flags( let flags = cmd.to_cli_flags(); validate_generated_cl_flags(&flags)?; - Ok(flags) + Ok(apply_version_compat(flags, image_tag)) } } } @@ -894,7 +841,7 @@ fn generate_default_consensus_cli_flags( let flags = cmd.to_cli_flags(); validate_generated_cl_flags(&flags)?; - Ok(flags) + Ok(apply_version_compat(flags, image_tag)) } /// Validate generated CL CLI flags by trial-parsing them against the actual @@ -1385,42 +1332,6 @@ mod tests { ); } - #[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)); - } - - #[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"))); - } - #[test] fn generate_consensus_cli_flags_includes_required_flags() { let flags = generate_consensus_cli_flags("validator-1", None, "172.19.0.5", &[], None, &[]) @@ -1997,6 +1908,91 @@ mod tests { ); } + #[test] + fn generate_consensus_cli_flags_omits_validator_for_images_that_predate_the_flag() { + let node = manifest::Node { + node_type: manifest::NodeType::Validator, + ..Default::default() + }; + let flags = generate_consensus_cli_flags( + "val-1", + Some(&node), + "172.19.0.5", + &[], + Some("arc_consensus:v0.6.0"), + &[], + ) + .unwrap(); + let flags_str = flags.join(" "); + assert!( + !flags_str.contains("--validator"), + "should not contain --validator for a pre-flag image: {flags_str}" + ); + } + + #[test] + fn generate_consensus_cli_flags_emits_validator_for_images_strictly_newer_than_last_unsupported( + ) { + let node = manifest::Node { + node_type: manifest::NodeType::Validator, + ..Default::default() + }; + for tag in ["arc_consensus:v0.6.1", "arc_consensus:v0.7.0"] { + let flags = generate_consensus_cli_flags( + "val-1", + Some(&node), + "172.19.0.5", + &[], + Some(tag), + &[], + ) + .unwrap(); + let flags_str = flags.join(" "); + assert!( + flags_str.contains("--validator"), + "missing --validator for tag {tag:?} (expected to postdate the flag): {flags_str}" + ); + } + } + + #[test] + fn generate_consensus_cli_flags_emits_validator_for_latest_image() { + let node = manifest::Node { + node_type: manifest::NodeType::Validator, + ..Default::default() + }; + let flags = generate_consensus_cli_flags( + "val-1", + Some(&node), + "172.19.0.5", + &[], + Some("arc_consensus:latest"), + &[], + ) + .unwrap(); + let flags_str = flags.join(" "); + assert!( + flags_str.contains("--validator"), + "missing --validator for the latest image: {flags_str}" + ); + } + + #[test] + fn generate_consensus_cli_flags_emits_validator_when_image_tag_missing() { + let node = manifest::Node { + node_type: manifest::NodeType::Validator, + ..Default::default() + }; + let flags = + generate_consensus_cli_flags("val-1", Some(&node), "172.19.0.5", &[], None, &[]) + .unwrap(); + let flags_str = flags.join(" "); + assert!( + flags_str.contains("--validator"), + "missing --validator when no image tag is given: {flags_str}" + ); + } + #[test] fn generate_consensus_cli_flags_includes_cl_prune_preset() { let node = manifest::Node { diff --git a/crates/quake/src/testnet.rs b/crates/quake/src/testnet.rs index 5c4dc28..64f16d7 100644 --- a/crates/quake/src/testnet.rs +++ b/crates/quake/src/testnet.rs @@ -1104,7 +1104,12 @@ impl Testnet { SSMSubcommand::Stop => infra.ssm_tunnels.stop().await, SSMSubcommand::List => infra.ssm_tunnels.list().await, }, - RemoteSubcommand::Destroy { yes } => infra.terraform.destroy(yes), + RemoteSubcommand::Destroy { yes } => { + if let Err(err) = infra.ssm_tunnels.stop().await { + warn!(%err, "⚠️ Failed to terminate SSM sessions before destroy"); + } + infra.terraform.destroy(yes) + } RemoteSubcommand::Ssh { node_or_cc, command, diff --git a/crates/spammer/src/erc20.rs b/crates/spammer/src/erc20.rs index 1f62a67..3063034 100644 --- a/crates/spammer/src/erc20.rs +++ b/crates/spammer/src/erc20.rs @@ -20,7 +20,7 @@ use alloy_sol_types::{sol, SolCall}; use color_eyre::eyre::Result; use crate::config::Erc20Function; -use crate::generator::{TxGenerator, TESTNET_CHAIN_ID}; +use crate::generator::{TxGenerator, MAX_FEE_PER_GAS, MAX_PRIORITY_FEE_PER_GAS, TESTNET_CHAIN_ID}; use crate::ws::WsClient; /// TestToken ERC-20 contract address (deterministic deployment in genesis). @@ -70,8 +70,8 @@ pub(crate) async fn prepare_erc20_tx( Ok(TxEip1559 { chain_id: TESTNET_CHAIN_ID, nonce, - max_priority_fee_per_gas: 1_000_000_000, // 1 gwei - max_fee_per_gas: 2_000_000_000, // 2 gwei + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + max_fee_per_gas: MAX_FEE_PER_GAS, gas_limit, to: Some(TEST_TOKEN_ADDRESS).into(), value: U256::ZERO, diff --git a/crates/spammer/src/generator.rs b/crates/spammer/src/generator.rs index aee897c..703ce97 100644 --- a/crates/spammer/src/generator.rs +++ b/crates/spammer/src/generator.rs @@ -37,6 +37,16 @@ use crate::ws::{WsClient, WsClientBuilder}; use crate::erc20::TEST_TOKEN_ADDRESS; pub(crate) const TESTNET_CHAIN_ID: u64 = 1337; + +/// Max fee per gas (in wei) used for all generated transactions. +/// +/// Sized for 2x headroom over the testnet `maxBaseFee` ceiling (20,000 gwei) so +/// the spammer keeps submitting when the base fee pegs at the ceiling. +pub(crate) const MAX_FEE_PER_GAS: u128 = 40_000 * 1_000_000_000; + +/// Max priority fee per gas (tip) in wei. +pub(crate) const MAX_PRIORITY_FEE_PER_GAS: u128 = 1_000_000_000; + const GUZZLER_ADDRESS: Address = address!("45a834A6bB86F516D4157a8cBcc60f2F35F8398C"); /// Generates and signs transactions from a pool of pre-funded genesis accounts. @@ -659,8 +669,8 @@ impl TxGenerator { TxEip1559 { chain_id: TESTNET_CHAIN_ID, nonce, - max_priority_fee_per_gas: 1_000_000_000, // 1 gwei - max_fee_per_gas: 2_000_000_000, // 2 gwei + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + max_fee_per_gas: MAX_FEE_PER_GAS, gas_limit: 30_000 + input_gas, // base tx + input gas, Arc requires ~26k for transfers (blocklist check) to: Address::left_padding_from(&(nonce.wrapping_add(0x1000)).to_be_bytes()).into(), // avoid zero address and Ethereum precompile addresses value: U256::from(1e16), // 0.01 ETH @@ -677,7 +687,7 @@ impl TxGenerator { TxLegacy { chain_id: Some(TESTNET_CHAIN_ID), nonce, - gas_price: 2_000_000_000, // 2 gwei + gas_price: MAX_FEE_PER_GAS, gas_limit: 30_000 + input_gas, to: Address::left_padding_from(&(nonce.wrapping_add(0x1000)).to_be_bytes()).into(), value: U256::from(1e16), // 0.01 ETH @@ -697,8 +707,8 @@ impl TxGenerator { TxEip1559 { chain_id: TESTNET_CHAIN_ID, nonce, - max_priority_fee_per_gas: 1_000_000_000, // 1 gwei - max_fee_per_gas: 2_000_000_000, // 2 gwei + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + max_fee_per_gas: MAX_FEE_PER_GAS, gas_limit, to: Some(addr).into(), value: U256::ZERO, diff --git a/crates/spammer/src/latency/tracker.rs b/crates/spammer/src/latency/tracker.rs index 25d63af..225d9b9 100644 --- a/crates/spammer/src/latency/tracker.rs +++ b/crates/spammer/src/latency/tracker.rs @@ -224,16 +224,26 @@ impl LatencyTracker { txs_finalized += self.drain_remaining_blocks(&mut block_receiver).await?; let txs_not_finalized = self.submitted_txs.len(); - info!( - "LatencyTracker finished: \ - {} txs found in blocks out of {} submitted, \ - {} txs not found in blocks. \ - CSV file: {}", - txs_finalized, - txs_submissions_recv, - txs_not_finalized, - self.csv_path.display(), - ); + if txs_finalized == 0 && txs_submissions_recv > 0 { + warn!( + "LatencyTracker finished: 0 txs found in blocks out of {} submitted — \ + check that eth_sendRawTransaction is being accepted by the node. \ + CSV file: {}", + txs_submissions_recv, + self.csv_path.display(), + ); + } else { + info!( + "LatencyTracker finished: \ + {} txs found in blocks out of {} submitted, \ + {} txs not found in blocks. \ + CSV file: {}", + txs_finalized, + txs_submissions_recv, + txs_not_finalized, + self.csv_path.display(), + ); + } self.csv_writer.flush()?; Ok(()) diff --git a/crates/spammer/src/sender.rs b/crates/spammer/src/sender.rs index dfcb9bc..cad56ca 100644 --- a/crates/spammer/src/sender.rs +++ b/crates/spammer/src/sender.rs @@ -209,6 +209,11 @@ impl TxSender { }; if let Some(tx) = tx { self.send(tx, wait_response).await?; + if !wait_response { + for client in &mut self.ws_clients { + client.drain_one().await; + } + } } else { break; } @@ -308,7 +313,7 @@ impl TxSender { tx.encode_2718(&mut buf); let tx_hash = compute_tx_hash(&buf); - let payload = hex::encode(buf); + let payload = format!("0x{}", hex::encode(buf)); let tx_len = tx_len as u64; let len = self.ws_clients.len(); diff --git a/crates/spammer/src/ws.rs b/crates/spammer/src/ws.rs index 3b709c2..ae1207d 100644 --- a/crates/spammer/src/ws.rs +++ b/crates/spammer/src/ws.rs @@ -230,6 +230,19 @@ impl WsClient { Err(eyre::eyre!("timeout waiting for notification")) } + /// Non-blockingly drain one pending inbound message. + /// + /// In fire-and-forget mode the sender never reads responses, so the TCP + /// receive buffer fills and eventually stalls the send path via flow control. + /// Calling this after each send keeps the pipe clear. + pub(crate) async fn drain_one(&mut self) { + tokio::select! { + biased; + _ = self.ws.next() => {} + _ = std::future::ready(()) => {} + } + } + /// Reconnect the WebSocket connection. /// This is useful when the connection breaks and needs to be re-established. pub async fn reconnect(&mut self) -> Result<()> { diff --git a/crates/test/checks/src/mev.rs b/crates/test/checks/src/mev.rs index deff546..618c718 100644 --- a/crates/test/checks/src/mev.rs +++ b/crates/test/checks/src/mev.rs @@ -228,7 +228,7 @@ async fn check_blocked( passed: false, message: format!( "{method} returned data! \ - Pending-tx filter is disabled (--arc.hide-pending-txs must be set)" + Pending-tx filter is disabled (unset --arc.expose-pending-txs or use --public-api)" ), }, RpcOutcome::Err { code, message } => CheckResult { diff --git a/crates/test/checks/src/perf.rs b/crates/test/checks/src/perf.rs index c3686b6..8d2a18b 100644 --- a/crates/test/checks/src/perf.rs +++ b/crates/test/checks/src/perf.rs @@ -1369,4 +1369,30 @@ grpc_server_handled_total{method="Propose"} 500 report.checks[0].message ); } + + #[test] + fn extract_raw_histogram_sum_before_buckets() { + // Reproduces the real CL output where _sum and _count come before _bucket lines. + let raw = r#"# HELP arc_malachite_app_block_transactions_count Number of transactions in each finalized block. +# TYPE arc_malachite_app_block_transactions_count histogram +arc_malachite_app_block_transactions_count_sum{moniker="validator1"} 163797.0 +arc_malachite_app_block_transactions_count_count{moniker="validator1"} 36233 +arc_malachite_app_block_transactions_count_bucket{moniker="validator1",le="1.0"} 32565 +arc_malachite_app_block_transactions_count_bucket{moniker="validator1",le="+Inf"} 36233 +"#; + let samples = parse_metrics(raw); + let hist = extract_raw_histogram(&samples, "arc_malachite_app_block_transactions_count"); + assert!(hist.is_some(), "histogram should be found"); + let hist = hist.unwrap(); + assert!( + (hist.sum - 163797.0).abs() < f64::EPSILON, + "sum should be 163797.0, got {}", + hist.sum + ); + assert!( + (hist.inf_count - 36233.0).abs() < f64::EPSILON, + "inf_count should be 36233, got {}", + hist.inf_count + ); + } } diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 5b52c31..e824f32 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -17,10 +17,12 @@ signer-local = ["dep:malachitebft-signing-ed25519"] # alloy alloy-consensus = { workspace = true } alloy-primitives = { workspace = true, features = ["serde"], default-features = false } +alloy-rlp = { workspace = true } alloy-rpc-types-engine = { workspace = true, features = ["ssz"] } # others arbitrary = { workspace = true, optional = true } +arc-shared = { workspace = true } bytes = { workspace = true } bytesize = { workspace = true } config = { workspace = true } diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 32fbdc4..d092efa 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -41,6 +41,7 @@ pub mod proposer; pub mod proto; pub mod rpc_sync; pub mod signing; +pub mod spec; pub mod ssz; pub mod sync; @@ -56,6 +57,7 @@ pub use crate::height::*; pub use crate::proposal::*; pub use crate::proposal_part::*; pub use crate::proposal_parts::*; +pub use crate::spec::*; pub use crate::validator_set::*; pub use crate::value::*; pub use crate::vote::*; diff --git a/crates/malachite-app/src/spec.rs b/crates/types/src/spec.rs similarity index 51% rename from crates/malachite-app/src/spec.rs rename to crates/types/src/spec.rs index aca1041..63f7870 100644 --- a/crates/malachite-app/src/spec.rs +++ b/crates/types/src/spec.rs @@ -14,10 +14,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Consensus-layer chain spec: activation conditions (height / time) per fork, per network. +//! Consensus-layer chain spec: per-fork activation conditions (by block height) per network. //! //! Used to branch logic for BLS commit certificates, ExecutionPayloadV4, etc., so that from a -//! given height or timestamp all validators use the new behavior. +//! given height all validators use the new behavior. use core::fmt; use std::str::FromStr; @@ -26,7 +26,7 @@ use alloy_rlp::RlpEncodable; use eyre::Context; use thiserror::Error; -use arc_consensus_types::{BlockHash, BlockTimestamp, Height, B256}; +use crate::{BlockHash, Height, B256}; pub use arc_shared::chain_ids; @@ -92,30 +92,23 @@ fn parse_chain_id(s: &str) -> eyre::Result { /// Consensus-layer fork version (0 = genesis, bump by 1 for each new fork). pub type ForkVersion = u32; -/// Activation condition for a consensus fork (by block height, timestamp, or both). +/// Activation condition for a consensus fork (by block height). +/// +/// Consensus-layer forks activate by block height only. This keeps `fork_version_at(height)` a +/// pure function of the argument — required so any two nodes resolve the same fork version for +/// the same height regardless of wall-clock state. Timestamp-based activation would require +/// block-timestamp lookups at every sign/verify site to stay consensus-safe. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ForkCondition { /// Active when block number >= height. Block(Height), - /// Active when block timestamp >= timestamp. - Timestamp(BlockTimestamp), - /// Active when both block number >= height and block timestamp >= timestamp. - BlockAndTime { - height: Height, - timestamp: BlockTimestamp, - }, } impl ForkCondition { - /// Returns true if this fork is active at the given block height and timestamp. - pub fn active_at(&self, height: Height, timestamp: BlockTimestamp) -> bool { + /// Returns true if this fork is active at the given block height. + pub fn active_at(&self, height: Height) -> bool { match self { ForkCondition::Block(h) => height >= *h, - ForkCondition::Timestamp(t) => timestamp >= *t, - ForkCondition::BlockAndTime { - height: h, - timestamp: t, - } => height >= *h && timestamp >= *t, } } } @@ -170,23 +163,27 @@ impl fmt::Display for NetworkId { /// Genesis fork version (version 0). pub const GENESIS_FORK_VERSION: ForkVersion = 0; -/// Consensus-layer chain spec: holds activation conditions for each fork per network. +/// A consensus-layer fork and the condition that activates it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsensusFork { + /// Fork version used for consensus-signature domain separation. + pub version: ForkVersion, + /// Activation condition for this fork. + pub condition: ForkCondition, +} + +/// Consensus-layer chain spec: holds an ordered fork history per network. /// -/// `current_fork_version` is the active version before the next fork. When `next_fork_condition` -/// is met at (height, timestamp), the effective fork version becomes `current_fork_version + 1`. -/// When you add a new CL fork, set `current_fork_version` to the version we're on now and -/// `next_fork_condition` to the new fork's activation. Individual `is_*_fork_activated` functions -/// duplicate the condition checks for convenience. +/// The schedule must include every fork from genesis onward. When adding a CL fork, append a new +/// entry instead of replacing the active version. That keeps historical signature verification +/// stable because `fork_version_at(old_height)` continues to resolve the fork version that was +/// active when the message was signed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ConsensusSpec { /// Chain ID for this spec. pub chain_id: ChainId, - /// Version we're on before the next fork (0 = genesis). When `next_fork_condition` is met we - /// transition to `current_fork_version + 1`. - pub current_fork_version: ForkVersion, - /// When this condition is active, we transition to `current_fork_version + 1`. Before that we - /// stay at `current_fork_version`. Set when scheduling the next fork. - pub next_fork_condition: Option, + /// Ordered fork history. Entries must be sorted by activation height, starting with genesis. + pub forks: &'static [ConsensusFork], // Example fork condition // /// From this block/height we use aggregated BLS in commit certificates (gossip + RPC). // pub bls_commit_certificate: Option, @@ -203,30 +200,37 @@ impl ConsensusSpec { } } - /// Returns the consensus-layer fork version active at the given block height and timestamp. - /// When `next_fork_condition` is met, returns `current_fork_version + 1`; otherwise - /// `current_fork_version`. - pub fn fork_version_at(&self, height: Height, timestamp: BlockTimestamp) -> ForkVersion { - match &self.next_fork_condition { - None => self.current_fork_version, - // Fork versions increment once per hardfork; u32::MAX is unreachable - #[allow(clippy::arithmetic_side_effects)] - Some(cond) if cond.active_at(height, timestamp) => self.current_fork_version + 1, - Some(_) => self.current_fork_version, + /// Returns the consensus-layer fork version active at the given block height. + pub fn fork_version_at(&self, height: Height) -> ForkVersion { + let mut active_version = GENESIS_FORK_VERSION; + + for fork in self.forks { + if !fork.condition.active_at(height) { + break; + } + + active_version = fork.version; } + + active_version } - /// Returns the condition for the next fork (for handshakes or display). When this is met we - /// transition to `current_fork_version + 1`. - pub fn next_fork_condition(&self) -> Option { - self.next_fork_condition + /// Returns the condition for the next scheduled fork after the given height. + pub fn next_fork_condition_after(&self, height: Height) -> Option { + for fork in self.forks { + if !fork.condition.active_at(height) { + return Some(fork.condition); + } + } + + None } // Example fork condition check - // /// Returns true if the BLS commit certificate fork is active at the given height and timestamp. - // pub fn is_bls_fork_activated(&self, height: Height, timestamp: BlockTimestamp) -> bool { + // /// Returns true if the BLS commit certificate fork is active at the given height. + // pub fn is_bls_fork_activated(&self, height: Height) -> bool { // self.bls_commit_certificate - // .is_some_and(|c| c.active_at(height, timestamp)) + // .is_some_and(|c| c.active_at(height)) // } } @@ -236,34 +240,83 @@ impl From for ConsensusSpec { } } +/// Validates that a fork history is well-formed. Panics in `const` context if: +/// - the slice is empty, +/// - the first entry is not genesis (height 0, `GENESIS_FORK_VERSION`), +/// - activation heights are not non-decreasing, or +/// - fork versions are not strictly increasing. +/// +/// Intended for `const _: () = validate_fork_history(SPEC.forks);` so malformed +/// schedules fail the build rather than waiting for a test run. +// `i` starts at 1, only increments, and the loop exits at `i == forks.len() <= isize::MAX`, +// so `i - 1` is always `>= 0` and `i + 1` cannot overflow. +#[allow(clippy::arithmetic_side_effects)] +const fn validate_fork_history(forks: &[ConsensusFork]) { + assert!(!forks.is_empty(), "fork history must not be empty"); + + let ForkCondition::Block(genesis_height) = forks[0].condition; + assert!( + genesis_height.as_u64() == 0, + "first fork must activate at height 0 (genesis)" + ); + assert!( + forks[0].version == GENESIS_FORK_VERSION, + "first fork must use GENESIS_FORK_VERSION" + ); + + let mut i = 1; + while i < forks.len() { + let ForkCondition::Block(prev_h) = forks[i - 1].condition; + let ForkCondition::Block(curr_h) = forks[i].condition; + + assert!( + curr_h.as_u64() >= prev_h.as_u64(), + "fork activation heights must be non-decreasing" + ); + assert!( + forks[i].version > forks[i - 1].version, + "fork versions must be strictly increasing" + ); + + i += 1; + } +} + +/// Consensus-layer genesis fork history. +pub const GENESIS_FORKS: &[ConsensusFork] = &[ConsensusFork { + version: GENESIS_FORK_VERSION, + condition: ForkCondition::Block(Height::new(0)), +}]; + /// Default / devnet consensus spec (genesis fork only). pub const DEVNET: ConsensusSpec = ConsensusSpec { chain_id: ChainId::Devnet, - current_fork_version: GENESIS_FORK_VERSION, - next_fork_condition: None, + forks: GENESIS_FORKS, }; -/// Testnet consensus spec (genesis fork only; set next_fork_condition when activation is scheduled). +/// Testnet consensus spec (genesis fork only; append a fork when activation is scheduled). pub const TESTNET: ConsensusSpec = ConsensusSpec { chain_id: ChainId::Testnet, - current_fork_version: GENESIS_FORK_VERSION, - next_fork_condition: None, + forks: GENESIS_FORKS, }; -/// Mainnet consensus spec (genesis fork only; set next_fork_condition when activation is scheduled). +/// Mainnet consensus spec (genesis fork only; append a fork when activation is scheduled). pub const MAINNET: ConsensusSpec = ConsensusSpec { chain_id: ChainId::Mainnet, - current_fork_version: GENESIS_FORK_VERSION, - next_fork_condition: None, + forks: GENESIS_FORKS, }; -/// Localdev consensus spec (genesis fork only; set next_fork_condition when activation is scheduled). +/// Localdev consensus spec (genesis fork only; append a fork when activation is scheduled). pub const LOCALDEV: ConsensusSpec = ConsensusSpec { chain_id: ChainId::Localdev, - current_fork_version: GENESIS_FORK_VERSION, - next_fork_condition: None, + forks: GENESIS_FORKS, }; +const _: () = validate_fork_history(MAINNET.forks); +const _: () = validate_fork_history(TESTNET.forks); +const _: () = validate_fork_history(DEVNET.forks); +const _: () = validate_fork_history(LOCALDEV.forks); + /// Error returned when the chain ID is not recognized. #[derive(Debug, Error)] #[error("Unknown chain ID {chain_id}; expected one of MAINNET (5042), TESTNET (5042002), DEVNET (5042001), LOCALDEV (1337)")] @@ -279,71 +332,160 @@ mod tests { Height::new(n) } - fn ts(secs: u64) -> BlockTimestamp { - secs - } - #[test] fn fork_condition_block() { let cond = ForkCondition::Block(h(100)); - assert!(!cond.active_at(h(99), ts(0))); - assert!(cond.active_at(h(100), ts(0))); - assert!(cond.active_at(h(101), ts(0))); + assert!(!cond.active_at(h(99))); + assert!(cond.active_at(h(100))); + assert!(cond.active_at(h(101))); } #[test] - fn fork_condition_timestamp() { - let cond = ForkCondition::Timestamp(ts(1000)); - assert!(!cond.active_at(h(0), ts(999))); - assert!(cond.active_at(h(0), ts(1000))); - assert!(cond.active_at(h(0), ts(1001))); + fn validate_fork_history_accepts_well_formed_schedule() { + const FORKS: &[ConsensusFork] = &[ + ConsensusFork { + version: GENESIS_FORK_VERSION, + condition: ForkCondition::Block(Height::new(0)), + }, + ConsensusFork { + version: 1, + condition: ForkCondition::Block(Height::new(100)), + }, + ConsensusFork { + version: 2, + condition: ForkCondition::Block(Height::new(100)), + }, + ]; + const _: () = validate_fork_history(FORKS); + validate_fork_history(FORKS); } #[test] - fn fork_condition_block_and_time() { - let cond = ForkCondition::BlockAndTime { - height: h(50), - timestamp: ts(500), - }; - assert!(!cond.active_at(h(49), ts(500))); - assert!(!cond.active_at(h(50), ts(499))); - assert!(cond.active_at(h(50), ts(500))); - assert!(cond.active_at(h(51), ts(501))); + #[should_panic(expected = "fork history must not be empty")] + fn validate_fork_history_rejects_empty() { + validate_fork_history(&[]); } #[test] - fn fork_version_at() { - // No next fork: always current_fork_version + #[should_panic(expected = "first fork must activate at height 0")] + fn validate_fork_history_rejects_non_zero_genesis_height() { + validate_fork_history(&[ConsensusFork { + version: GENESIS_FORK_VERSION, + condition: ForkCondition::Block(Height::new(1)), + }]); + } + + #[test] + #[should_panic(expected = "first fork must use GENESIS_FORK_VERSION")] + fn validate_fork_history_rejects_non_genesis_first_version() { + validate_fork_history(&[ConsensusFork { + version: 1, + condition: ForkCondition::Block(Height::new(0)), + }]); + } + + #[test] + #[should_panic(expected = "fork activation heights must be non-decreasing")] + fn validate_fork_history_rejects_decreasing_heights() { + validate_fork_history(&[ + ConsensusFork { + version: GENESIS_FORK_VERSION, + condition: ForkCondition::Block(Height::new(0)), + }, + ConsensusFork { + version: 1, + condition: ForkCondition::Block(Height::new(100)), + }, + ConsensusFork { + version: 2, + condition: ForkCondition::Block(Height::new(50)), + }, + ]); + } + + #[test] + #[should_panic(expected = "fork versions must be strictly increasing")] + fn validate_fork_history_rejects_non_increasing_versions() { + validate_fork_history(&[ + ConsensusFork { + version: GENESIS_FORK_VERSION, + condition: ForkCondition::Block(Height::new(0)), + }, + ConsensusFork { + version: GENESIS_FORK_VERSION, + condition: ForkCondition::Block(Height::new(100)), + }, + ]); + } + + #[test] + fn fork_version_at_returns_genesis_when_only_genesis_is_scheduled() { let spec = ConsensusSpec { chain_id: ChainId::Localdev, - current_fork_version: 0, - next_fork_condition: None, + forks: GENESIS_FORKS, }; - assert_eq!(spec.fork_version_at(h(0), ts(0)), 0); + assert_eq!(spec.fork_version_at(h(0)), GENESIS_FORK_VERSION); + assert_eq!(spec.fork_version_at(h(1_000_000)), GENESIS_FORK_VERSION); + } - // Next fork at block 100, current = 0: before 100 -> 0, at/after 100 -> 1 + #[test] + fn fork_version_at() { + const FORKS: &[ConsensusFork] = &[ + ConsensusFork { + version: 0, + condition: ForkCondition::Block(Height::new(0)), + }, + ConsensusFork { + version: 1, + condition: ForkCondition::Block(Height::new(100)), + }, + ConsensusFork { + version: 2, + condition: ForkCondition::Block(Height::new(200)), + }, + ]; let spec = ConsensusSpec { chain_id: ChainId::Localdev, - current_fork_version: 0, - next_fork_condition: Some(ForkCondition::Block(h(100))), + forks: FORKS, }; - assert_eq!(spec.fork_version_at(h(0), ts(0)), 0); - assert_eq!(spec.fork_version_at(h(99), ts(0)), 0); - assert_eq!(spec.fork_version_at(h(100), ts(0)), 1); - assert_eq!(spec.fork_version_at(h(200), ts(0)), 1); + + assert_eq!(spec.fork_version_at(h(0)), 0); + assert_eq!(spec.fork_version_at(h(99)), 0); + assert_eq!(spec.fork_version_at(h(100)), 1); + assert_eq!(spec.fork_version_at(h(199)), 1); + assert_eq!(spec.fork_version_at(h(200)), 2); + assert_eq!(spec.fork_version_at(h(1_000_000)), 2); } #[test] - fn next_fork_condition() { + fn next_fork_condition_after() { + const FORKS: &[ConsensusFork] = &[ + ConsensusFork { + version: 0, + condition: ForkCondition::Block(Height::new(0)), + }, + ConsensusFork { + version: 1, + condition: ForkCondition::Block(Height::new(100)), + }, + ConsensusFork { + version: 2, + condition: ForkCondition::Block(Height::new(200)), + }, + ]; let spec = ConsensusSpec { chain_id: ChainId::Localdev, - current_fork_version: 0, - next_fork_condition: Some(ForkCondition::Block(h(100))), + forks: FORKS, }; assert_eq!( - spec.next_fork_condition(), + spec.next_fork_condition_after(h(0)), Some(ForkCondition::Block(h(100))) ); + assert_eq!( + spec.next_fork_condition_after(h(100)), + Some(ForkCondition::Block(h(200))) + ); + assert_eq!(spec.next_fork_condition_after(h(200)), None); } #[test] @@ -397,16 +539,15 @@ mod tests { // fn is_bls_fork_activated() { // let spec = ConsensusSpec { // chain_id: None, - // current_fork_version: 0, - // next_fork_condition: None, + // forks: GENESIS_FORKS, // // bls_commit_certificate: Some(ForkCondition::Block(h(50))), // }; - // assert!(!spec.is_bls_fork_activated(h(49), ts(0))); - // assert!(spec.is_bls_fork_activated(h(50), ts(0))); - // assert!(spec.is_bls_fork_activated(h(51), ts(0))); + // assert!(!spec.is_bls_fork_activated(h(49))); + // assert!(spec.is_bls_fork_activated(h(50))); + // assert!(spec.is_bls_fork_activated(h(51))); // // let spec_no_bls = ConsensusSpec::default(); - // assert!(!spec_no_bls.is_bls_fork_activated(h(0), ts(0))); + // assert!(!spec_no_bls.is_bls_fork_activated(h(0))); // } #[test] diff --git a/docs/running-an-arc-node.md b/docs/running-an-arc-node.md index 895a85f..b3429dc 100644 --- a/docs/running-an-arc-node.md +++ b/docs/running-an-arc-node.md @@ -153,6 +153,11 @@ The `--rpc.forwarder` parameter routes requests not served locally to an existin The `arc-node-execution` binary accepts all parameters of a `reth` node. Refer to its [documentation](https://reth.rs/cli/reth/node/) for details. +For externally-reachable nodes, consider adding `--public-api`. It +enforces hiding of pending-tx RPCs (a potential MEV vector) and warns if +`--http.api` / `--ws.api` exposes namespaces beyond the safe set +(`eth`, `net`, `web3`, `rpc`). + ### Start consensus layer After starting the [execution layer](#start-execution-layer), in a different terminal, start the consensus layer: diff --git a/scripts/ci/check-coverage-sharding.sh b/scripts/ci/check-coverage-sharding.sh new file mode 100755 index 0000000..1925a91 --- /dev/null +++ b/scripts/ci/check-coverage-sharding.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Guard against workspace crates falling out of the coverage sharding matrix. +# +# Every crate in `cargo metadata` workspace_members should appear in some +# shard's `-p` list in .github/workflows/ci.yml, OR in the allowlist below. +# +# When adding a new workspace crate, either: +# 1. Add `-p ` to one of the shards in ci.yml, or +# 2. Add its name to ALLOWLIST with a one-line reason. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CI_YAML="$ROOT/.github/workflows/ci.yml" + +# Crates that intentionally don't appear in any shard. +# Keep comments aligned with reason so the next reader can audit at a glance. +ALLOWLIST=( + quake-macros # proc-macro crate, no tests of its own; exercised transitively via quake +) + +workspace_crates=$( + cargo metadata --manifest-path "$ROOT/Cargo.toml" --no-deps --format-version 1 \ + | jq -r '.packages[].name' \ + | sort -u +) + +# Extract every `-p ` token from the coverage-shard job block. +# Awk slices the file to the block, grep picks out each -p token, awk emits +# the crate name. Avoids a yq dependency (not installed on every runner). +sharded_crates=$( + awk ' + /^ coverage-shard:$/ { in_block = 1; next } + in_block && /^ [a-z][a-z_-]*:$/ { exit } + in_block + ' "$CI_YAML" \ + | grep -oE -- '-p [a-zA-Z0-9_-]+' \ + | awk '{print $2}' \ + | sort -u +) + +allowlist_crates=$(printf '%s\n' "${ALLOWLIST[@]}" | sort -u) + +# Crates present in the workspace but absent from both shards and allowlist. +missing=$( + comm -23 \ + <(echo "$workspace_crates") \ + <(cat <(echo "$sharded_crates") <(echo "$allowlist_crates") | sort -u) +) + +# Crates referenced in the matrix that no longer exist in the workspace. +stale=$( + comm -23 \ + <(echo "$sharded_crates") \ + <(echo "$workspace_crates") +) + +status=0 + +if [ -n "$missing" ]; then + echo "error: workspace crates missing from coverage sharding matrix:" >&2 + echo "$missing" | sed 's/^/ - /' >&2 + echo "" >&2 + echo "Either add '-p ' to a shard in $CI_YAML, or add the crate to" >&2 + echo "the ALLOWLIST in $(basename "$0") with a one-line reason." >&2 + status=1 +fi + +if [ -n "$stale" ]; then + echo "error: shard '-p' arguments reference crates that no longer exist:" >&2 + echo "$stale" | sed 's/^/ - /' >&2 + echo "" >&2 + echo "Remove these from the matrix in $CI_YAML." >&2 + status=1 +fi + +if [ "$status" -eq 0 ]; then + echo "coverage sharding ok: $(echo "$workspace_crates" | wc -l | tr -d ' ') workspace crates, $(echo "$sharded_crates" | wc -l | tr -d ' ') sharded, $(echo "$allowlist_crates" | wc -l | tr -d ' ') allowlisted." +fi + +exit "$status" diff --git a/scripts/ci/s3-cache-restore.sh b/scripts/ci/s3-cache-restore.sh new file mode 100755 index 0000000..2d6c570 --- /dev/null +++ b/scripts/ci/s3-cache-restore.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Restore the cargo cache for the current coverage shard from S3. +# +# Reads SHARD and KEY from the environment. On any failure (miss, tiny +# object, download error, archive corruption, partial extract), exits 0 +# with the touched paths wiped so the surrounding workflow proceeds with +# a clean cold build instead of a half-restored cache. +# +# Extracts with absolute-path tar (-P) so entries land at their original +# locations: $GITHUB_WORKSPACE/target/... and $HOME/.cargo/{registry,git}/... +set -euo pipefail + +BUCKET=circle-cicd-artifacts +OBJECT_KEY="rust-cache/arc-node-${SHARD}-${KEY}.tar.zst" + +# Paths the archive will populate. Kept in sync with s3-cache-save.sh; used +# for cleanup if extraction aborts mid-way. +RESTORE_PATHS=( + "$GITHUB_WORKSPACE/target/llvm-cov-target" + "$HOME/.cargo/registry/index" + "$HOME/.cargo/registry/cache" + "$HOME/.cargo/git/db" +) + +wipe_partial_restore() { + for p in "${RESTORE_PATHS[@]}"; do + rm -rf "$p" + done +} + +echo "HOME=$HOME" +echo "GITHUB_WORKSPACE=$GITHUB_WORKSPACE" + +if ! aws s3api head-object --bucket "$BUCKET" --key "$OBJECT_KEY" >/dev/null 2>&1; then + echo "S3 cache miss (cold build): $OBJECT_KEY" + exit 0 +fi + +SIZE=$(aws s3api head-object --bucket "$BUCKET" --key "$OBJECT_KEY" --query ContentLength --output text) +echo "S3 cache hit: $OBJECT_KEY (size=$SIZE bytes)" + +# A valid cache is hundreds of MB; reject obviously-truncated objects. +# A prior bug once saved a near-0-byte object that fooled the restore path. +if [ "$SIZE" -lt 10485760 ]; then + echo "object smaller than 10 MB, treating as cold miss" + exit 0 +fi + +# Download first, validate, then extract. Streaming directly into / is faster +# but would leave partially-restored cache dirs if zstd or tar fails mid-way, +# which would poison the next cargo build with ghost .rlib / fingerprint files. +TMP_TAR=$(mktemp --tmpdir="${RUNNER_TEMP:-/tmp}" s3cache.XXXXXXXX.tar.zst) +trap 'rm -f "$TMP_TAR"' EXIT + +echo "downloading archive to $TMP_TAR..." +if ! aws s3 cp "s3://$BUCKET/$OBJECT_KEY" "$TMP_TAR"; then + echo "download failed, treating as cold miss" + exit 0 +fi + +echo "validating archive integrity..." +if ! zstd -t "$TMP_TAR" >/dev/null 2>&1; then + echo "archive failed zstd integrity check, treating as cold miss" + exit 0 +fi + +echo "extracting..." +if ! zstd -dc "$TMP_TAR" | tar -x -P -C /; then + echo "extract failed after validation; wiping partial restore and falling back to cold build" + wipe_partial_restore + exit 0 +fi + +echo "restored target + cargo registry from S3" +du -sh "$GITHUB_WORKSPACE/target/llvm-cov-target" 2>/dev/null || true +du -sh "$HOME/.cargo/registry" "$HOME/.cargo/git" 2>/dev/null || true diff --git a/scripts/ci/s3-cache-save.sh b/scripts/ci/s3-cache-save.sh new file mode 100755 index 0000000..5ef3688 --- /dev/null +++ b/scripts/ci/s3-cache-save.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Save the cargo cache for the current coverage shard to S3. +# +# Reads SHARD and KEY from the environment. Bundles compiled artifacts and +# the cargo registry/git caches so cargo's mtime-based fingerprint check +# stays consistent across runs. Omitting the registry forces cargo to +# re-fetch sources with fresh mtimes and rebuild every dep on the next run. +# +# Excludes mirror Swatinem/rust-cache: registry/src and git/checkouts are +# derived from cache/ and db/ respectively, so cargo recreates them. +# target/**/incremental is unused (CARGO_INCREMENTAL=0 is the cargo default +# in CI) and only bloats the tarball. +# +# *.profraw / *.profdata / *.info are coverage profile artifacts produced by +# this run. They must NOT be saved: cargo llvm-cov uses --no-clean, so a +# restored profraw from a previous run would fold into the next run's lcov +# and silently bias coverage numbers. +set -euo pipefail + +BUCKET=circle-cicd-artifacts +OBJECT_KEY="rust-cache/arc-node-${SHARD}-${KEY}.tar.zst" + +TARGETS=() +[ -d "$GITHUB_WORKSPACE/target/llvm-cov-target" ] && TARGETS+=("$GITHUB_WORKSPACE/target/llvm-cov-target") +[ -d "$HOME/.cargo/registry/index" ] && TARGETS+=("$HOME/.cargo/registry/index") +[ -d "$HOME/.cargo/registry/cache" ] && TARGETS+=("$HOME/.cargo/registry/cache") +[ -d "$HOME/.cargo/git/db" ] && TARGETS+=("$HOME/.cargo/git/db") +if [ ${#TARGETS[@]} -eq 0 ]; then + echo "nothing to save, skipping" + exit 0 +fi +echo "saving: ${TARGETS[*]}" +du -sh "${TARGETS[@]}" 2>/dev/null || true + +tar -c -P \ + --exclude='*/incremental' \ + --exclude='*.profraw' \ + --exclude='*.profdata' \ + --exclude='*.info' \ + "${TARGETS[@]}" \ + | zstd -3 -T0 \ + | aws s3 cp - "s3://$BUCKET/$OBJECT_KEY" + +echo "saved cache to S3: $OBJECT_KEY" diff --git a/scripts/genesis/ProtocolConfig.ts b/scripts/genesis/ProtocolConfig.ts index af7df3f..379f8f7 100644 --- a/scripts/genesis/ProtocolConfig.ts +++ b/scripts/genesis/ProtocolConfig.ts @@ -64,10 +64,6 @@ export const schemaProtocolConfig = z */ pauser: schemaAddress, - /** - * The initial beneficiary, which can receive the block rewards. - */ - beneficiary: schemaAddress, feeParams: z.object({ alpha: schemaBigInt.min(0n).max(100n), kRate: schemaBigInt.min(0n).max(10000n), @@ -100,7 +96,6 @@ export const buildProtocolConfigGenesisAllocs = async (ctx: BuilderContext, conf owner, controller, pauser, - beneficiary, feeParams, consensusParams, } = schemaProtocolConfig.parse(config) @@ -153,7 +148,8 @@ export const buildProtocolConfigGenesisAllocs = async (ctx: BuilderContext, conf * Slot 1: minBaseFee (uint256) * Slot 2: maxBaseFee (uint256) * Slot 3: blockGasLimit (uint256) - * Slot 4: rewardBeneficiary (address) + * Slot 4: _reserved_address (address) — address placeholder; + * left zero at genesis. Slot retained to avoid storage migration on upgrade. * Slot 5: ConsensusParams packed (8 * uint16 = 128 bits) */ /* @@ -172,25 +168,24 @@ export const buildProtocolConfigGenesisAllocs = async (ctx: BuilderContext, conf StorageSlot(slotIndex(PROTOCOL_CONFIG_STORAGE_LOCATION + 1n), toBytes32(feeParams.minBaseFee)), StorageSlot(slotIndex(PROTOCOL_CONFIG_STORAGE_LOCATION + 2n), toBytes32(feeParams.maxBaseFee)), StorageSlot(slotIndex(PROTOCOL_CONFIG_STORAGE_LOCATION + 3n), toBytes32(feeParams.blockGasLimit)), - // rewardBeneficiary in slot 4 - StorageSlot(slotIndex(PROTOCOL_CONFIG_STORAGE_LOCATION + 4n), addressToBytes32(beneficiary)), + // Slot 4 is the deprecated address placeholder — genesis leaves it zero. // ConsensusParams packed into one slot (8 * uint16 = 128 bits) in slot 5 ...(consensusParams ? [ - StorageSlot( - slotIndex(PROTOCOL_CONFIG_STORAGE_LOCATION + 5n), - toBytes32( - consensusParams.timeoutProposeMs | - (consensusParams.timeoutProposeDeltaMs << 16n) | - (consensusParams.timeoutPrevoteMs << 32n) | - (consensusParams.timeoutPrevoteDeltaMs << 48n) | - (consensusParams.timeoutPrecommitMs << 64n) | - (consensusParams.timeoutPrecommitDeltaMs << 80n) | - (consensusParams.timeoutRebroadcastMs << 96n) | - (consensusParams.targetBlockTimeMs << 112n), - ), + StorageSlot( + slotIndex(PROTOCOL_CONFIG_STORAGE_LOCATION + 5n), + toBytes32( + consensusParams.timeoutProposeMs | + (consensusParams.timeoutProposeDeltaMs << 16n) | + (consensusParams.timeoutPrevoteMs << 32n) | + (consensusParams.timeoutPrevoteDeltaMs << 48n) | + (consensusParams.timeoutPrecommitMs << 64n) | + (consensusParams.timeoutPrecommitDeltaMs << 80n) | + (consensusParams.timeoutRebroadcastMs << 96n) | + (consensusParams.targetBlockTimeMs << 112n), ), - ] + ), + ] : []), ], }) diff --git a/scripts/genesis/addresses.ts b/scripts/genesis/addresses.ts index e580376..8e12ff8 100644 --- a/scripts/genesis/addresses.ts +++ b/scripts/genesis/addresses.ts @@ -29,7 +29,7 @@ export const callFromAddress = '0x1800000000000000000000000000000000000003' as c // predeployed contracts export const deterministicDeployerProxyAddress = '0x4e59b44847b379578588920ca78fbf26c0b4956c' as const export const multicall3Address = '0xcA11bde05977b3631167028862bE2a173976CA11' as const -export const multicall3FromAddress = '0x825F535677d346626cDE45D64cf89C2a426467e0' as const +export const multicall3FromAddress = '0xA3E6c63b16321E39a61551Dc1A38689b04d62E42' as const // Denylist proxy address. Deterministic CREATE2-derived with prefix 0x360. // Init-code: AdminUpgradeableProxy bytecode + abi.encode(implementation, proxyAdmin, initData). @@ -39,8 +39,11 @@ export const multicall3FromAddress = '0x825F535677d346626cDE45D64cf89C2a426467e0 // // Salt: 0x2e8184e0b708cc70e9f829091612c4c8efef8006ee7527c73bdbbd70b64c36c8 export const denylistAddress = '0x360Eb67EDbA456Bbe01512679f36c2717AA65121' as const -export const memoAddress = '0xe4aa7Ed3585AEf598179f873086F75Fcd6D4b755' as const +export const memoAddress = '0x5294E9927c3306DcBaDb03fe70b92e01cCede505' as const export const gasGuzzlerAddress = '0x45a834A6bB86F516D4157a8cBcc60f2F35F8398C' as const export const testTokenAddress = '0x298122B4bF05CC897662e535C18417f44C7f274b' as const +// Genesis block coinbase on localdev and devnet; also Quake's default +// `cl_suggested_fee_recipient` fallback (`QUAKE_DEFAULT_FEE_RECIPIENT` in +// `crates/quake/src/setup.rs`). export const localdevFeeRecipient = '0x65E0a200006D4FF91bD59F9694220dafc49dbBC1' as const diff --git a/scripts/hardhat/tasks/query-state.ts b/scripts/hardhat/tasks/query-state.ts index 06ff698..27687e1 100644 --- a/scripts/hardhat/tasks/query-state.ts +++ b/scripts/hardhat/tasks/query-state.ts @@ -130,7 +130,6 @@ task('query-state', 'query state for the network') cmp.eq('ProtocolConfig.owner', state, config) cmp.eq('ProtocolConfig.controller', state, config) cmp.eq('ProtocolConfig.pauser', state, config) - cmp.eq('ProtocolConfig.beneficiary', state, config) cmp.eq('ProtocolConfig.feeParams', state, config) // Verify ProtocolConfig bytecode @@ -205,13 +204,12 @@ const queryProtocolConfig = async ( client, }).read - const [admin, impl, owner, controller, pauser, beneficiary, feeParams, consensusParams] = await Promise.all([ + const [admin, impl, owner, controller, pauser, feeParams, consensusParams] = await Promise.all([ protocolConfig.admin(), protocolConfig.implementation(), protocolConfig.owner(), protocolConfig.controller(), protocolConfig.pauser(), - protocolConfig.rewardBeneficiary(), protocolConfig.feeParams(), protocolConfig.consensusParams().then((params) => ({ timeoutProposeMs: BigInt(params.timeoutProposeMs), @@ -231,7 +229,6 @@ const queryProtocolConfig = async ( owner, controller, pauser, - beneficiary, feeParams, consensusParams, } diff --git a/tests/helpers/networks/index.ts b/tests/helpers/networks/index.ts index 98c416b..dde9df2 100644 --- a/tests/helpers/networks/index.ts +++ b/tests/helpers/networks/index.ts @@ -20,7 +20,7 @@ import hre from 'hardhat' import * as localdev from './localdev' import { schemaGenesisConfig } from '../../../scripts/genesis' -export { LOCALDEV_FEE_RECIPIENT } from './localdev' +export { LOCALDEV_FEE_RECIPIENT, LOCALDEV_FEE_RECIPIENTS } from './localdev' /** * Get the clients for the current network diff --git a/tests/helpers/networks/localdev.ts b/tests/helpers/networks/localdev.ts index 167bb60..5aeec41 100644 --- a/tests/helpers/networks/localdev.ts +++ b/tests/helpers/networks/localdev.ts @@ -26,6 +26,16 @@ import { expect } from 'chai' // Used as genesis coinbase and cl_suggested_fee_recipient across all localdev validators. export const LOCALDEV_FEE_RECIPIENT: Address = localdevFeeRecipient +// Per-validator fee recipients used by `crates/quake/scenarios/localdev.toml`. +// Index `i` corresponds to `validator${i + 1}` in the manifest. +export const LOCALDEV_FEE_RECIPIENTS: readonly Address[] = [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333', + '0x4444444444444444444444444444444444444444', + '0x5555555555555555555555555555555555555555', +] as const + /** * Get the clients for the localdev network * @returns The clients for the localdev network diff --git a/tests/localdev/NativeFiatToken.test.ts b/tests/localdev/NativeFiatToken.test.ts index 9d173fc..85a6ed9 100644 --- a/tests/localdev/NativeFiatToken.test.ts +++ b/tests/localdev/NativeFiatToken.test.ts @@ -20,7 +20,6 @@ import { NativeCoinAuthority, NativeTransferHelper, ReceiptVerifier, - LOCALDEV_FEE_RECIPIENT, getClients, } from '../helpers' import { signPermit, USDC } from '../helpers/FiatToken' @@ -83,8 +82,8 @@ describe('NativeFiatToken', () => { const receipt = await USDC.attach(sender) .write.transfer([receiver.account.address, amount]) .then(ReceiptVerifier.waitSuccess) - // Zero5: EIP-2929 warm/cold gas pricing - receipt.verifyGasUsedApproximately(54638n).verifyEvents((ev) => { + // Zero6: EIP-2929 warm/cold account-load pricing + receipt.verifyGasUsedApproximately(51038n).verifyEvents((ev) => { ev.expectNativeTransfer({ from: sender, to: receiver, amount: USDC.toNative(amount) }) .expectUSDCTransfer({ from: sender, to: receiver, value: amount }) .expectAllEventsMatched() @@ -447,7 +446,6 @@ describe('NativeFiatToken', () => { // Setup balance tracking AFTER blocklist operation to avoid interference const balances = await balancesSnapshot(client, { - beneficiary: LOCALDEV_FEE_RECIPIENT, sender: sender.account.address, receiver: receiver.address, }) @@ -460,13 +458,19 @@ describe('NativeFiatToken', () => { // Calculate gas fees and verify balance changes const totalFee = receiptVerifier.totalFee() - // Verify that gas was consumed from sender and reward sent to beneficiary + // Verify the actual block miner received the gas fees. Derived from block.miner + // (not hard-coded) so this works under both smoke scenarios — reth --dev always + // mines to the genesis coinbase, malachite rotates per proposer. + const block = await client.getBlock({ blockHash: receiptVerifier.blockHash }) + const [minerBefore, minerAfter] = await Promise.all([ + client.getBalance({ address: block.miner, blockNumber: block.number - 1n }), + client.getBalance({ address: block.miner, blockNumber: block.number }), + ]) + expect(minerAfter - minerBefore, `miner ${block.miner} should receive gas fees`).to.equal(totalFee) + await balances - .increase({ - beneficiary: totalFee, // Beneficiary receives gas fees - }) .decrease({ - sender: totalFee, // Sender pays the gas fees + sender: totalFee, }) .verify() diff --git a/tests/localdev/ProtocolConfig.test.ts b/tests/localdev/ProtocolConfig.test.ts index 64ef82b..aa9fcbb 100644 --- a/tests/localdev/ProtocolConfig.test.ts +++ b/tests/localdev/ProtocolConfig.test.ts @@ -80,9 +80,11 @@ describe('ProtocolConfig Smoke Tests', function () { return { txHash, receipt, block } } - // Helper to send transaction, verify miner, and check balance changes + // Helper to send transaction, verify miner receives the full fee, and check balance changes. + // The miner is derived from the block (not passed in) so this works under both smoke + // scenarios — reth --dev always mines to the genesis coinbase, malachite rotates per + // proposer. The miner's fee delta is checked via block-granular historical balances. async function sendTransactionAndVerifyBalances(params: { - beneficiary: Address transferAmount?: bigint gasPrice?: bigint maxFeePerGas?: bigint @@ -90,7 +92,6 @@ describe('ProtocolConfig Smoke Tests', function () { transactionType?: 'legacy' | 'eip1559' }) { const { - beneficiary, transferAmount = parseEther('0.01'), // Default transfer amount maxFeePerGas, maxPriorityFeePerGas, @@ -128,9 +129,7 @@ describe('ProtocolConfig Smoke Tests', function () { } } - // Setup balance tracking const balances = await balancesSnapshot(publicClient, { - beneficiary, sender: sender.account.address, receiver: receiver.account.address, }) @@ -147,16 +146,21 @@ describe('ProtocolConfig Smoke Tests', function () { const receiptVerifier = ReceiptVerifier.build(receipt) const totalFee = receiptVerifier.totalFee() - // Verify block miner matches beneficiary if (!block.miner) { throw new Error('Block miner is undefined') } - expectAddressEq(block.miner, beneficiary, 'Block miner should match beneficiary') - // Verify balance changes + // Assert Arc's full-fee-to-miner invariant via block-boundary balance delta. + // This covers Arc's divergence from standard Ethereum (no base-fee burn) under + // both smoke-reth (fixed miner) and smoke-malachite (rotating miner). + const [minerBefore, minerAfter] = await Promise.all([ + publicClient.getBalance({ address: block.miner, blockNumber: block.number - 1n }), + publicClient.getBalance({ address: block.miner, blockNumber: block.number }), + ]) + expect(minerAfter - minerBefore, `miner ${block.miner} should receive full tx fee`).to.equal(totalFee) + await balances .increase({ - beneficiary: totalFee, receiver: transferAmount, }) .decrease({ @@ -196,12 +200,16 @@ describe('ProtocolConfig Smoke Tests', function () { }) describe('Core Integration', function () { - it('should use LOCALDEV_FEE_RECIPIENT as block miner', async function () { - // The mock CL propagates LOCALDEV_FEE_RECIPIENT as block.miner - const { block } = await sendTransactionAndGetBlock(parseEther('0.01'), 1000000000000n, 100000000n) - - expectAddressEq(block.miner, LOCALDEV_FEE_RECIPIENT, 'Block miner should be LOCALDEV_FEE_RECIPIENT') - }) + // Only holds under smoke-reth: reth --dev uses the genesis coinbase + // (= LOCALDEV_FEE_RECIPIENT) as block.miner. smoke-malachite rotates. + ;(process.env.ARC_SMOKE_SCENARIO !== 'malachite' ? it : it.skip)( + 'should use LOCALDEV_FEE_RECIPIENT as block miner', + async function () { + const { block } = await sendTransactionAndGetBlock(parseEther('0.01'), 1000000000000n, 100000000n) + + expectAddressEq(block.miner, LOCALDEV_FEE_RECIPIENT, 'Block miner should be LOCALDEV_FEE_RECIPIENT') + }, + ) }) describe('Fee Distribution', function () { @@ -228,7 +236,6 @@ describe('ProtocolConfig Smoke Tests', function () { const transferAmount = parseEther('0.05') const { receipt, totalFee } = await sendTransactionAndVerifyBalances({ - beneficiary: LOCALDEV_FEE_RECIPIENT, transferAmount, transactionType: 'eip1559', }) diff --git a/tests/localdev/genesis.test.ts b/tests/localdev/genesis.test.ts index edb79fe..ae2bca7 100644 --- a/tests/localdev/genesis.test.ts +++ b/tests/localdev/genesis.test.ts @@ -31,7 +31,6 @@ import { parseAbi, parseGwei, toHex, - zeroAddress, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { @@ -301,19 +300,16 @@ describe('genesis', () => { it('initial addresses', async () => { const { protocolConfig, expectAddr } = await clients() - const [admin, owner, controller, pauser, beneficiary] = await Promise.all([ + const [admin, owner, controller, pauser] = await Promise.all([ protocolConfig.admin(), protocolConfig.owner(), protocolConfig.controller(), protocolConfig.pauser(), - protocolConfig.rewardBeneficiary(), ]) expect(admin).to.be.addressEqual(expectAddr.proxyAdmin) expect(owner.toLowerCase()).to.be.eq(expectAddr.admin) expect(controller.toLowerCase()).to.be.eq(expectAddr.admin) expect(pauser.toLowerCase()).to.be.eq(expectAddr.admin) - // Zero sentinel: EL honors the CL-provided --suggested-fee-recipient per validator. - expect(beneficiary.toLowerCase()).to.be.eq(zeroAddress) }) it('fee params', async () => { diff --git a/tests/localdev/per_validator_fees.test.ts b/tests/localdev/per_validator_fees.test.ts new file mode 100644 index 0000000..7b41d36 --- /dev/null +++ b/tests/localdev/per_validator_fees.test.ts @@ -0,0 +1,91 @@ +// 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 { Address, parseGwei, zeroAddress } from 'viem' +import { getClients, LOCALDEV_FEE_RECIPIENT, LOCALDEV_FEE_RECIPIENTS } from '../helpers' + +// Only runs under `make smoke-malachite` (ARC_SMOKE_SCENARIO=malachite). Requires +// `localdev.toml` (per-validator recipients); smoke-reth (reth --dev) doesn't +// rotate proposers. The EL uses the validator-supplied beneficiary +// unconditionally, so no ProtocolConfig setup is needed here. +;(process.env.ARC_SMOKE_SCENARIO === 'malachite' ? describe : describe.skip)( + 'per-validator fee accrual (malachite)', + () => { + it('routes fees to every per-validator recipient as proposer rotates', async function () { + this.timeout(180_000) + + const { client, sender } = await getClients() + + const [initialPerValidator, initialDefault, initialZero] = await Promise.all([ + Promise.all(LOCALDEV_FEE_RECIPIENTS.map((addr) => client.getBalance({ address: addr }))), + client.getBalance({ address: LOCALDEV_FEE_RECIPIENT }), + client.getBalance({ address: zeroAddress }), + ]) + + // Send sequentially so each tx lands in its own block; collect the proposer + // of every landed block directly from block.miner. + const numTxs = 25 + const miners = new Set
() + for (let i = 0; i < numTxs; i++) { + const hash = await sender.sendTransaction({ + to: sender.account.address, + value: 1n, + maxFeePerGas: parseGwei('1000'), + maxPriorityFeePerGas: parseGwei('10'), + }) + const receipt = await client.waitForTransactionReceipt({ hash }) + const block = await client.getBlock({ blockNumber: receipt.blockNumber }) + miners.add(block.miner) + } + + const [finalPerValidator, finalDefault, finalZero] = await Promise.all([ + Promise.all(LOCALDEV_FEE_RECIPIENTS.map((addr) => client.getBalance({ address: addr }))), + client.getBalance({ address: LOCALDEV_FEE_RECIPIENT }), + client.getBalance({ address: zeroAddress }), + ]) + + const deltas = finalPerValidator.map((final, i) => final - initialPerValidator[i]) + const accrued = deltas.filter((d) => d > 0n).length + const deltaSummary = deltas.map((d, i) => `recipient${i + 1}=${d}`).join(', ') + + // Every one of our txs landed in a block proposed by one of the 5 validators; + // observing all 5 proves rotation covered our sample (not just the chain's + // history). + expect(miners.size).to.equal( + 5, + `Expected all 5 proposers to produce blocks we landed in; observed ${miners.size} (${[...miners].join(', ')}).`, + ) + + expect(accrued).to.equal( + 5, + `Expected all 5 per-validator recipients to accrue fees; ${accrued} did. ${deltaSummary}.`, + ) + + // Negative controls: fees must not leak to the single-recipient fallback + // or to the zero address. A mis-configured validator (missing + // cl_suggested_fee_recipient) would fall back to LOCALDEV_FEE_RECIPIENT. + expect(finalDefault - initialDefault).to.equal( + 0n, + `LOCALDEV_FEE_RECIPIENT received ${finalDefault - initialDefault} wei; should be zero under per-validator routing.`, + ) + expect(finalZero - initialZero).to.equal( + 0n, + `zeroAddress received ${finalZero - initialZero} wei; should be zero.`, + ) + }) + }, +) diff --git a/tests/simulation/ERC7201-migration.test.ts b/tests/simulation/ERC7201-migration.test.ts deleted file mode 100644 index 39e2112..0000000 --- a/tests/simulation/ERC7201-migration.test.ts +++ /dev/null @@ -1,315 +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. - -import { expect } from 'chai' -import hre from 'hardhat' -import { getChain } from '../../scripts/hardhat/viem-helper' -import { ProtocolConfig } from '../helpers' -import { loadGenesisConfig } from '../helpers' -import { parseAbi, Address, decodeFunctionResult, encodeFunctionData, Hex } from 'viem' -import fs from 'fs' - -describe('ProtocolConfig ERC-7201 Migration', () => { - const genesisConfig = loadGenesisConfig() - if (!genesisConfig) { - return - } - - const clients = async () => { - const client = await hre.viem.getPublicClient({ - chain: getChain(hre), - }) - const protocolConfig = ProtocolConfig.attach(client) - return { client, protocolConfig } - } - - const getNewImplementation = () => process.env.NEW_IMPLEMENTATION_ADDRESS as Address | undefined - const newImplementation = getNewImplementation() as Address - - ;(newImplementation && genesisConfig?.ProtocolConfig?.proxy?.admin ? describe : describe.skip)( - 'Migration Tests', - () => { - it('should verify new implementation contract is deployed', async () => { - const { client } = await clients() - - const deployedCode = await client.getCode({ address: newImplementation }) - expect(deployedCode, `No code found at ${newImplementation}`).to.not.be.undefined - expect(deployedCode).to.not.equal('0x') - - const forgeArtifactPath = 'contracts/out/forge/ProtocolConfig.sol/ProtocolConfig.json' - const forgeArtifact = JSON.parse(fs.readFileSync(forgeArtifactPath, 'utf8')) as { - deployedBytecode: { object: Hex } - } - const forgeBytecode = forgeArtifact.deployedBytecode.object - - expect(deployedCode).to.equal(forgeBytecode) - }) - - it('should verify new implementation has expected interface', async () => { - const { client } = await clients() - - const viewCalls = await client.multicall({ - contracts: [ - { - address: newImplementation, - abi: parseAbi(['function controller() view returns (address)']), - functionName: 'controller', - }, - { - address: newImplementation, - abi: parseAbi(['function pauser() view returns (address)']), - functionName: 'pauser', - }, - { - address: newImplementation, - abi: parseAbi(['function paused() view returns (bool)']), - functionName: 'paused', - }, - { - address: newImplementation, - abi: parseAbi(['function owner() view returns (address)']), - functionName: 'owner', - }, - { - address: newImplementation, - abi: parseAbi(['function rewardBeneficiary() view returns (address)']), - functionName: 'rewardBeneficiary', - }, - ], - allowFailure: true, - }) - - expect(viewCalls[0].status).to.equal('success') - expect(viewCalls[1].status).to.equal('success') - expect(viewCalls[2].status).to.equal('success') - expect(viewCalls[3].status).to.equal('success') - expect(viewCalls[4].status).to.equal('success') - }) - - it('should preserve all storage values after migration', async () => { - const { client, protocolConfig } = await clients() - const admin = await protocolConfig.read.admin() - - const oldController = await protocolConfig.read.controller() - const oldPauser = await protocolConfig.read.pauser() - const oldPaused = await protocolConfig.read.paused() - const oldOwner = await protocolConfig.read.owner() - const oldFeeParams = await protocolConfig.read.feeParams() - const oldBeneficiary = await protocolConfig.read.rewardBeneficiary() - - const initData = encodeFunctionData({ - abi: parseAbi(['function initialize(address,address,bool)']), - functionName: 'initialize', - args: [oldController, oldPauser, oldPaused], - }) - - const result = await client.simulateBlocks({ - blocks: [ - { - calls: [ - { - account: admin, - to: protocolConfig.address, - abi: parseAbi(['function upgradeToAndCall(address,bytes)']), - functionName: 'upgradeToAndCall', - args: [newImplementation, initData], - }, - { - account: admin, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'controller', - args: [], - }, - { - account: admin, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'pauser', - args: [], - }, - { - account: admin, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'paused', - args: [], - }, - { - account: admin, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'owner', - args: [], - }, - { - account: admin, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'feeParams', - args: [], - }, - { - account: admin, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'rewardBeneficiary', - args: [], - }, - ], - }, - ], - }) - - const calls = result[0].calls - expect(calls[0].status).to.equal('success') - - expect( - decodeFunctionResult({ abi: protocolConfig.abi, functionName: 'controller', data: calls[1].data }), - ).to.equal(oldController) - expect(decodeFunctionResult({ abi: protocolConfig.abi, functionName: 'pauser', data: calls[2].data })).to.equal( - oldPauser, - ) - expect(decodeFunctionResult({ abi: protocolConfig.abi, functionName: 'paused', data: calls[3].data })).to.equal( - oldPaused, - ) - expect(decodeFunctionResult({ abi: protocolConfig.abi, functionName: 'owner', data: calls[4].data })).to.equal( - oldOwner, - ) - - const newFeeParams = decodeFunctionResult({ - abi: protocolConfig.abi, - functionName: 'feeParams', - data: calls[5].data, - }) - expect(newFeeParams.blockGasLimit).to.equal(oldFeeParams.blockGasLimit) - - expect( - decodeFunctionResult({ abi: protocolConfig.abi, functionName: 'rewardBeneficiary', data: calls[6].data }), - ).to.equal(oldBeneficiary) - }) - - it('should prevent re-initialization', async () => { - const { client, protocolConfig } = await clients() - const admin = await protocolConfig.read.admin() - const controller = await protocolConfig.read.controller() - const pauser = await protocolConfig.read.pauser() - const paused = await protocolConfig.read.paused() - - const initData = encodeFunctionData({ - abi: parseAbi(['function initialize(address,address,bool)']), - functionName: 'initialize', - args: [controller, pauser, paused], - }) - - const result = await client.simulateBlocks({ - blocks: [ - { - calls: [ - { - account: admin, - to: protocolConfig.address, - abi: parseAbi(['function upgradeToAndCall(address,bytes)']), - functionName: 'upgradeToAndCall', - args: [newImplementation, initData], - }, - { - account: admin, - to: protocolConfig.address, - abi: parseAbi(['function initialize(address,address,bool)']), - functionName: 'initialize', - args: [controller, pauser, paused], - }, - ], - }, - ], - }) - - expect(result[0].calls[0].status).to.equal('success') - expect(result[0].calls[1].status).to.equal('failure') - }) - - it('should preserve controller functionality after migration', async () => { - const { client, protocolConfig } = await clients() - const admin = await protocolConfig.read.admin() - const controller = genesisConfig.ProtocolConfig.controller - - const oldController = await protocolConfig.read.controller() - const oldPauser = await protocolConfig.read.pauser() - const oldPaused = await protocolConfig.read.paused() - const oldFeeParams = await protocolConfig.read.feeParams() - - const initData = encodeFunctionData({ - abi: parseAbi(['function initialize(address,address,bool)']), - functionName: 'initialize', - args: [oldController, oldPauser, oldPaused], - }) - - const newFeeParams = { - alpha: oldFeeParams.alpha + 1n, - kRate: oldFeeParams.kRate + 1n, - inverseElasticityMultiplier: oldFeeParams.inverseElasticityMultiplier + 1n, - minBaseFee: oldFeeParams.minBaseFee + 1n, - maxBaseFee: oldFeeParams.maxBaseFee + 1n, - blockGasLimit: oldFeeParams.blockGasLimit + 1n, - } - - const result = await client.simulateBlocks({ - blocks: [ - { - calls: [ - { - account: admin, - to: protocolConfig.address, - abi: parseAbi(['function upgradeToAndCall(address,bytes)']), - functionName: 'upgradeToAndCall', - args: [newImplementation, initData], - }, - { - account: controller, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'updateFeeParams', - args: [newFeeParams], - }, - { - account: controller, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'feeParams', - args: [], - }, - ], - }, - ], - }) - - const calls = result[0].calls - expect(calls[0].status).to.equal('success') - expect(calls[1].status).to.equal('success') - expect(calls[2].status).to.equal('success') - - const returnedFeeParams = decodeFunctionResult({ - abi: protocolConfig.abi, - functionName: 'feeParams', - data: calls[2].data, - }) - expect(returnedFeeParams.alpha).to.equal(newFeeParams.alpha) - expect(returnedFeeParams.blockGasLimit).to.equal(newFeeParams.blockGasLimit) - }) - }, - ) -}) diff --git a/tests/simulation/ProtocolConfig.test.ts b/tests/simulation/ProtocolConfig.test.ts index e2e06dc..d2192aa 100644 --- a/tests/simulation/ProtocolConfig.test.ts +++ b/tests/simulation/ProtocolConfig.test.ts @@ -23,7 +23,6 @@ import { type FeeParams, type ConsensusParams, } from '../helpers' -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { Address, decodeFunctionResult, encodeFunctionData, parseAbi, zeroAddress } from 'viem' import { multicall3Address } from '../../scripts/genesis' import { schemaHex } from '../../scripts/genesis/types' @@ -40,10 +39,9 @@ describe('ProtocolConfig simulation', () => { chain: getChain(hre), }) const protocolConfig = ProtocolConfig.attach(client) - const randomWallet = privateKeyToAccount(generatePrivateKey()) const extraAbi = parseAbi(['function upgradeTo(address newImplementation)']) - return { client, randomWallet, protocolConfig, extraAbi } + return { client, protocolConfig, extraAbi } } it('migrate contract', async () => { @@ -120,57 +118,6 @@ describe('ProtocolConfig simulation', () => { expect(parsedPaused).to.equal(true, 'ProtocolConfig should report paused after pause()') }) - it('controller wallet from genesis config can update controller-only fields', async function () { - const controllerWallet = protocolConfigGenesis?.controller - expect(controllerWallet).to.not.be.undefined - - const { client, protocolConfig, randomWallet } = await clients() - const [onchainController] = await Promise.all([ - protocolConfig.read.controller(), - protocolConfig.read.rewardBeneficiary(), - ]) - expect(onchainController).to.addressEqual(controllerWallet, 'on-chain controller differs from genesis config') - - const beneficiaryReadCall = { - account: controllerWallet, - to: ProtocolConfig.address, - data: encodeFunctionData({ abi: protocolConfig.abi, functionName: 'rewardBeneficiary', args: [] }), - } - - const calls = [ - { - account: controllerWallet, - to: ProtocolConfig.address, - data: encodeFunctionData({ - abi: protocolConfig.abi, - functionName: 'updateRewardBeneficiary', - args: [randomWallet.address], - }), - }, - beneficiaryReadCall, - ] - - const result = await client.simulateBlocks({ blocks: [{ calls }] }) - const executedCalls = result[0]?.calls ?? [] - expect(executedCalls.length).to.equal(calls.length) - - executedCalls.forEach((call, idx) => { - expect(call.error).to.be.undefined - expect(call.status).to.equal('success', `call ${idx} should succeed`) - }) - - const benecifiaryRead = executedCalls[1]?.data - expect(benecifiaryRead).to.not.be.undefined - - const newBeneficiary = decodeFunctionResult({ - abi: protocolConfig.abi, - functionName: 'rewardBeneficiary', - data: schemaHex.parse(benecifiaryRead), - }) - - expect(newBeneficiary).to.addressEqual(randomWallet.address) - }) - it('controller can push fee params near new upper bounds', async function () { const { client, protocolConfig } = await clients() const [originalFeeParams, chainController] = await Promise.all([ diff --git a/tests/simulation/ProtocolConfig.upgrade.test.ts b/tests/simulation/ProtocolConfig.upgrade.test.ts index e578c44..2d197c6 100644 --- a/tests/simulation/ProtocolConfig.upgrade.test.ts +++ b/tests/simulation/ProtocolConfig.upgrade.test.ts @@ -23,7 +23,6 @@ import { getChain } from '../../scripts/hardhat/viem-helper' import { ProtocolConfig } from '../helpers' import { loadGenesisConfig } from '../helpers' import { parseAbi, Address, decodeFunctionResult, Hex } from 'viem' -import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' import fs from 'fs' /** @@ -140,11 +139,7 @@ describe('ProtocolConfig upgrade simulation', () => { targetBlockTimeMs: currentConsensusParams.targetBlockTimeMs + 1, } - // Generate a random address for new beneficiary - const randomAccount = privateKeyToAccount(generatePrivateKey()) - const newBeneficiary = randomAccount.address - - // Simulate: Upgrade + Update FeeParams + Update ConsensusParams + Update Beneficiary + Read to verify + // Simulate: Upgrade + Update FeeParams + Update ConsensusParams + Read to verify const result = await client.simulateBlocks({ blocks: [ { @@ -173,15 +168,7 @@ describe('ProtocolConfig upgrade simulation', () => { functionName: 'updateConsensusParams', args: [newConsensusParams], }, - // 4. Update reward beneficiary (verify beneficiary update works) - { - account: controller, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'updateRewardBeneficiary', - args: [newBeneficiary], - }, - // 5. Read fee params back (verify read works) + // 4. Read fee params back (verify read works) { account: controller, to: protocolConfig.address, @@ -189,7 +176,7 @@ describe('ProtocolConfig upgrade simulation', () => { functionName: 'feeParams', args: [], }, - // 6. Read consensus params back (verify read works) + // 5. Read consensus params back (verify read works) { account: controller, to: protocolConfig.address, @@ -197,14 +184,6 @@ describe('ProtocolConfig upgrade simulation', () => { functionName: 'consensusParams', args: [], }, - // 7. Read reward beneficiary back (verify read works) - { - account: controller, - to: protocolConfig.address, - abi: protocolConfig.abi, - functionName: 'rewardBeneficiary', - args: [], - }, ], }, ], @@ -215,28 +194,20 @@ describe('ProtocolConfig upgrade simulation', () => { expect(calls[0].status).to.equal('success', 'Upgrade failed') expect(calls[1].status).to.equal('success', 'Update fee params failed after upgrade') expect(calls[2].status).to.equal('success', 'Update consensus params failed after upgrade') - expect(calls[3].status).to.equal('success', 'Update reward beneficiary failed after upgrade') - expect(calls[4].status).to.equal('success', 'Read fee params failed after upgrade') - expect(calls[5].status).to.equal('success', 'Read consensus params failed after upgrade') - expect(calls[6].status).to.equal('success', 'Read reward beneficiary failed after upgrade') + expect(calls[3].status).to.equal('success', 'Read fee params failed after upgrade') + expect(calls[4].status).to.equal('success', 'Read consensus params failed after upgrade') // Verify the returned values match the new params const returnedFeeParams: any = decodeFunctionResult({ abi: protocolConfig.abi, functionName: 'feeParams', - data: calls[4].data, + data: calls[3].data, }) const returnedConsensusParams: any = decodeFunctionResult({ abi: protocolConfig.abi, functionName: 'consensusParams', - data: calls[5].data, - }) - - const returnedBeneficiary: any = decodeFunctionResult({ - abi: protocolConfig.abi, - functionName: 'rewardBeneficiary', - data: calls[6].data, + data: calls[4].data, }) // Verify fee params were updated @@ -283,9 +254,6 @@ describe('ProtocolConfig upgrade simulation', () => { newConsensusParams.targetBlockTimeMs, 'targetBlockTimeMs not updated', ) - - // Verify reward beneficiary was updated - expect(returnedBeneficiary).to.equal(newBeneficiary, 'Reward beneficiary not updated') }) }, )