From b2807751f410df8569e623021bb10f716a239d9d Mon Sep 17 00:00:00 2001 From: circle-github-action-bot Date: Fri, 26 Jun 2026 16:34:59 +0200 Subject: [PATCH] chore: sync v0.7.3 to arc-node - c90a4450ce760cb216b09ac0c3e538ae787f4cec feat(NoStory): add consensus missed-round metric (main) (... by circle-github-action-bot <[internal]> - 899b612437b1277a978b27991b209a8acc21be64 chore(NoStory): cherry-pick #1352 and #2269 from develop ... by circle-github-action-bot <[internal]> - c4e591b3cea6d534ed04824d9bf5957e5f3e78a8 chore(NoStory): add private release orchestration (#2463) by circle-github-action-bot <[internal]> - 55f01331610be74dae36aaf2441b631e130d0374 ci(NoStory): bump remote-signer dev image to current ECR ... by circle-github-action-bot <[internal]> - c833791874111010d3859d400e91d06de90529ca fix: redact ZScaler infra comments from bloc... by circle-github-action-bot <[internal]> - 7658b8a33f4b0373758c5b9f0a9a0e96694549b4 ci(NoStory): port build-once + max-parallel to nightly ch... by circle-github-action-bot <[internal]> - 5d99fa7b4b2172a8e89f3d4d7125f50e2be84bd4 ci(NoStory): fix nightly random-manifests runner starvati... by circle-github-action-bot <[internal]> - 68ea1ae75db1b31a5d0fb2bf7a107b1a6c2f4f41 feat: charge delegatecall early-revert penal... by circle-github-action-bot <[internal]> - d26f9ef1a1dc12e708e6ba03ed8837c5bb1a1748 fix(NoStory): remediate cargo audit advisories (#2512) by circle-github-action-bot <[internal]> - 6210fc4dead75961fa3aa8676616f1c3b3dc3bfb chore(NoStory): update Malachite to `circlefin/malachite@... by circle-github-action-bot <[internal]> - 3bbe50280b9b17dd16ff1f52125017333b3ab3db feat: introduce Zero8 hardfork (dormant) (#2... by circle-github-action-bot <[internal]> - fcb501b8b1332bc31e90263ff7c33b8a5072bd17 fix: complete ArcRpcLayer pending-block filt... by circle-github-action-bot <[internal]> - 302caa2e7e8fe4d7123f8ef563deb98fa3f2cafa feat: accept hex block numbers in arc_getCertificate (#24... by circle-github-action-bot <[internal]> - e41047579cd220ec89fafc49b2af8217ae3868f9 doc: close spec and test gaps from public-re... by circle-github-action-bot <[internal]> - e9f80bbcafcc4bafed8f69e5973f4cebc61c1ec0 fix(NoStory): revise ADR-004 (#2377) by circle-github-action-bot <[internal]> - 8c98d6550b7cde7227f6e771217c7fae3dea02dd chore(NoStory): scope v0.7.2 Zero7 activation to testnet ... by circle-github-action-bot <[internal]> - 96fe12d20b34878c0c76fea4c8f858e557b284d4 fix: stop stale proposal streams from blocki... by circle-github-action-bot <[internal]> - 765a5c616657cc8ed6896ee5b750d9fdf8a0f3ef chore: redact internal references from publi... by circle-github-action-bot <[internal]> - d0d3f77633c5c0eaf4821035e07c1af43d983f95 chore(NoStory): activate Zero7 on devnet, testnet, mainne... by circle-github-action-bot <[internal]> - 5863e485b4d2a3145f4fa7664858f1a5a937a032 fix: preserve cold state for selfdestruct ch... by circle-github-action-bot <[internal]> - 60f8eb0bc20541e910e4cd0edc2b1defdbb91db0 fix(cchain-1841): by default disallow RPC-submissions of ... by circle-github-action-bot <[internal]> - 38d51243dbe3a84cc0a0101389e44fe2dc3de3b5 fix: default --rpc.gascap to 30M (#2248) by circle-github-action-bot <[internal]> - 79f2c3a5eabee8bda85c1e1db78a1c84cb92c5a0 feat: upgrade validator-registry for devnet/... by circle-github-action-bot <[internal]> - d7f029143f8c36cd543e05ef2f1ffd515d991fac audit: cap JSON-RPC batch size, default inva... by circle-github-action-bot <[internal]> - c58e2426fcc0dc4a47bf690d6f2e93e5949e9c44 chore(NoStory): bump Malachite to `8ee5d998` (#2206) by circle-github-action-bot <[internal]> - 181371e2e9ac17c36774b621717e96d69cec57fc fix(NoStory): add version info to spec test binary (#2222) by circle-github-action-bot <[internal]> - 42bc0df06c84d9b586fffd251e4270249cf640a3 fix: charge gas before storage I/O in precom... by circle-github-action-bot <[internal]> - fdada00b0105426e87fff70f9799aef2bd839186 chore(NoStory): cook snapshots in docker dependency layer... by circle-github-action-bot <[internal]> - a3005e2111e9c163e6042337d384b61cc8f01902 test(NoStory): stabilize per-validator fee accrual smoke ... by circle-github-action-bot <[internal]> - d7599099092f36c9220fd11616143b2c9674a8c1 fix(cchain-1866): fail closed in SELFDESTRUCT handler on ... by circle-github-action-bot <[internal]> - 6ecc47fb46c66d286436ed47a654b3366968e48e docs: add RPC provider node section to public... by circle-github-action-bot <[internal]> - 76a051f0f537f1f075d57c74f42305620a160e19 doc: sweep v0.7.1 CHANGELOG and BREAKING_CHA... by circle-github-action-bot <[internal]> - 60160bb1732fe95238383bf6a5d0869805981ac7 chore: import arc-node PR #64: docs: clean up README word... by circle-github-action-bot <[internal]> - 44eed3cf693b01951df1662671c7f18e090dede8 chore: import arc-node PR #69: minor refactor to use impl... by circle-github-action-bot <[internal]> - 01074a905e69604533e259464438a658291efd5c chore: publish mainnet genesis, drop cfg gat... by circle-github-action-bot <[internal]> - 250c5e7ded6a2a275806f43c5abc470d85143316 chore: Update docs for v0.7.1-rc2 (#2181) by circle-github-action-bot <[internal]> - a664dc6d97a8897513640141548d6b01addef0b6 fix(NoStory): fix fork id (#2176) by circle-github-action-bot <[internal]> - 8ad6031f2c04dc12f1224f9637008a9394b25cfc audit: RPC conn defaults & config (#2158) by circle-github-action-bot <[internal]> - 9813b75209ca3d90ce9da3fb3ac52acaa89fb50d chore: import arc-node PR #73: docs: fix duplicate "the" ... by circle-github-action-bot <[internal]> - 502b4c070d69ee21f464adfab2a01d1250239392 fix(cchain-1732): selectively overwrite create2 scheme wi... by circle-github-action-bot <[internal]> - a157d435fbf247a86e0a56c2a8a1a5a3cf753dc8 fix: wire latency emulation through to remot... by circle-github-action-bot <[internal]> - 7227417bf29513efb3b90938db16fea42967b63b doc: seed public CHANGELOG.md entry for v0.7... by circle-github-action-bot <[internal]> - 88dd1065b89aadd293b24fbcc80116e349b1258a chore: enable keccak-cache-global and asm-keccak (#2107) by circle-github-action-bot <[internal]> - fdf4bc1f9f9a5bfd43c27c8247b61e6575489410 fix(NO-STORY): random manifest's sentries should enable c... by circle-github-action-bot <[internal]> - a3e67b1240ae41a1a9e9939ee4f7d443089e30d6 chore: activate Zero5 + Zero6 on devnet and testnet (#2149) by circle-github-action-bot <[internal]> - 8aa29d4674aeafc387286bcbe351e52a3c614402 chore: touch up CHANGELOG and BREAKING_CHANGES (#2127) by circle-github-action-bot <[internal]> - 940bc43c399e5e484a79c1fe02dea697264c6948 doc: seed public CHANGELOG.md and BREAKING_C... by circle-github-action-bot <[internal]> - cb3d9264088e95c846b690bd1423e9d7c085fcd0 test: add nightly tx propagation check (#2092) by circle-github-action-bot <[internal]> - d86cb94fb29ba5bcefa3225884a75bc20a8452d8 fix: enforce subcall completion gas limit (#... by circle-github-action-bot <[internal]> - 34b9b7a6fc281846a2ee13bd541ec119c56f198d feat: expose per-run telemetry from the spam... by circle-github-action-bot <[internal]> - 5791a7cd77b7ecfab1d92f7431d35b6c6ce8ce5d fix: use configured EVM spec for instruction... by circle-github-action-bot <[internal]> - 3588ea94a31f211a133ceb7720e31d945029627a feat: fix simulation for mainnet (#2096) by circle-github-action-bot <[internal]> - 8a35d4a10f6fad51901cfa8929435b0b90df6c5e feat: finalize mainnet genesis for launch (#... by circle-github-action-bot <[internal]> - 1b549b00755f7242b0af57c0f19aedf838d28415 chore(NoStory): filter arc system accounts for computing ... by circle-github-action-bot <[internal]> - 54fc12f37aba4a94a0b2485bae4a76fe2c9682dd feat: register arc-mainnet as named chainspe... by circle-github-action-bot <[internal]> - 074e3961a38ae6518c89e1f61dc5cc250efded6b test: cover EIP-7702 subcall edge cases (#20... by circle-github-action-bot <[internal]> - 6e5a9d4e4ad949551be29a587f15a4c759c50a02 feat: merge load and remote load into single... by circle-github-action-bot <[internal]> - 2117d033739fb0a7103a058665b4c9e38575a20e fix(quake): build spammer docker image from full workspac... by circle-github-action-bot <[internal]> - 8651f7dfff116f0b963488cd0867cefb728ef1c3 feat: fill in remaining Genesis addresses (#... by circle-github-action-bot <[internal]> - fb98e5c6501934235797c2d13fa74b332ba80c41 feat: redeploy denylist on devnet (#2076) by circle-github-action-bot <[internal]> - 64f09104a53d23904e0ad13de73271db1a4ab29f feat: prefund Arc ops wallet (#2061) by circle-github-action-bot <[internal]> - 4f9bdfdbfd03aad21665985b6a5e6629696d4427 fix(noStory): Surface duplicate Init/Fin proposal parts a... by circle-github-action-bot <[internal]> - 149bd046971bfa66643b3353629d7366ce06c214 fix: resolve EIP-7702 delegation when loadin... by circle-github-action-bot <[internal]> - 849d6d00107e395b9bf17674a1b35ea23238dff0 fix(NoStory): exclude perf group from default quake test ... by circle-github-action-bot <[internal]> - a913a6e673b17e93539cc943d9226b19fc37662b fix: suppress pool-based pending tx leaks in... by circle-github-action-bot <[internal]> - 7786e17cff12870e70f4d22edc23a675dca2c850 refactor(NoStory): defer subcall continuation insert unti... by circle-github-action-bot <[internal]> - 72799da383cc053a51f9e2dcbb695ad34c9c7801 feat: defined USDC admin roles on Arc mainne... by circle-github-action-bot <[internal]> - 29bd97d1e653be8f210fe52f90bfe3577c914de7 feat: add dedicated chainspec bounds for mai... by circle-github-action-bot <[internal]> - c0340e06532ed3b1ea0bcd0a2b9b1f1658131e0e feat: support `extra_account_balance_usdc` a... by circle-github-action-bot <[internal]> - ae53603086f31148cd1b76a38ffaef54e675d004 feat: add EL and CL CPU/memory limits to man... by circle-github-action-bot <[internal]> - f6a449878ed4770fd4d5a56cd30559bff0b1f970 chore: reduce value-sync REQUEST_TIMEOUT fro... by circle-github-action-bot <[internal]> - a95fc9fa8639af94b104f67a007c0d361de8d52f fix: Quake persistent peer address expansion... by circle-github-action-bot <[internal]> - fd124ceeb9a90df5849827a595bba92608a0f6b8 chore(NoStory): bump Malachite to `0422137f` (#2018) by circle-github-action-bot <[internal]> - b3474282e26589d6bb15646e1b76a68875e126ba fix: Mark undecided block Invalid on engine ... by circle-github-action-bot <[internal]> - c8ecd7a501953278a1bc3a288fbdd83ad1792a0e feat: apply 0xef to Arc precompile addresses ... by circle-github-action-bot <[internal]> - c189e2b04f91abc2ee33d84f1d2184e237940d54 feat: deploy Denylist on devnet/testnet (#17... by circle-github-action-bot <[internal]> - 1a94334dd6dfa403e281a89da83373c5e19231c5 feat: introduce mainnet genesis (#1880) by circle-github-action-bot <[internal]> - b57e385ce79c8cdf3eeff69a7a863fee8641e5d3 fix: Add `init: true` to Quake Docker node c... by circle-github-action-bot <[internal]> - 3f03201132d08debb983d9133bd073deacda29c5 feat: add `node_volume_type` and `node_volum... by circle-github-action-bot <[internal]> - bf67fcf737d87796327ab1b761063a3163f7460a fix: pass secrets to Terraform via env vars ... by circle-github-action-bot <[internal]> - fe0af2f37f2b51a65da8233e63bd14b81f5914a5 fix(NoStory): `skip --auto-approve` when running terrafor... by circle-github-action-bot <[internal]> - ea7b852d520247f23a2c0c15748c78e9a4348e69 chore: default quake `arc.builder.deadline` ... by circle-github-action-bot <[internal]> - 5486ae04e83d2b72274602ff535eccfe6a1d1bbf fix: add new account surcharge via precompil... by circle-github-action-bot <[internal]> - cbbfc83bcc04ab6ec6f1a41cf3358a38e8464a61 feat(NoStory): add Zero7 hardfork for batch and memo cont... by circle-github-action-bot <[internal]> - 00abcf2eb3983f3c0dbb3c59e871e7d83386e1f3 feat: update protocol-config upgrade script ... by circle-github-action-bot <[internal]> - 0fe5e21e332a0f8378c6bfca3b1558fe96f8ff3d fix: impl EIP-2200 sentry for sstore (#2006) by circle-github-action-bot <[internal]> - 5c97f1fcc67c60aa24a817bbed144ab52b00ed01 test: integration tests with real in-process... by circle-github-action-bot <[internal]> - 688206ae733be82d8c9d573672d43c7913aba912 test(NoStory): stabilize per-validator fee accrual test (... by circle-github-action-bot <[internal]> - 63ec0aa0b651ec39ad55c5d11ca28cf387911ebc fix: correct count of pre funded accounts (#... by circle-github-action-bot <[internal]> - 4d424867d985f9272030bf38512fc4daa349518d fix: Skip persistence wait during sync if bl... by circle-github-action-bot <[internal]> - 3990dd2ef942b6bd0002d03b3f113dfd68b44d63 fix: consume all gas for subcall in static c... by circle-github-action-bot <[internal]> - 5319bc14c65192cb798dde24402520cab03a94d4 fix: regenerate localdev genesis after Pausa... by circle-github-action-bot <[internal]> - df8a0712979d217b7403c5e0f59532e1b2be4739 fix: address inconsistent behavioral convent... by circle-github-action-bot <[internal]> - ad39090013e7f428d1f44f85ae750e6356b4cefe fix(1782): Strictly decode ABI parameters in precompiles ... by circle-github-action-bot <[internal]> - 81445b37dcf83e486942dcd21753a3884c4e31fd fix: drop redundant SLOAD charge in storeGas... by circle-github-action-bot <[internal]> - 070d94218b243008f28e1cde64bf5bf84a2ac663 chore(NoStory): update PQ precompile dependency and guida... by circle-github-action-bot <[internal]> - a72d625f94cb1168239f6be15c5bf149d4274a1b fix: remove blocklist SLOAD gas cost (#1969) by circle-github-action-bot <[internal]> - 845e0178122511e9192645497d10c6478176b665 fix: revert Controller.getVotingPowerLimit w... by circle-github-action-bot <[internal]> - d8ae67e23b40a0764996913bce3fe04c5411e837 fix: capture Multicall3From precompile rever... by circle-github-action-bot <[internal]> - 92ed1fb989c63c53137a9946af45e914391501b1 fix: charge gas for subcall completion phase... by circle-github-action-bot <[internal]> (And 339 more changes) GitOrigin-RevId: c90a4450ce760cb216b09ac0c3e538ae787f4cec --- .cargo/audit.toml | 20 + .gitignore | 4 + Cargo.lock | 73 +- Cargo.toml | 26 +- assets/localdev/genesis.json | 1 + crates/evm-node/Cargo.toml | 1 + crates/evm-node/src/rpc/arc.rs | 8 +- crates/evm-node/src/rpc/common.rs | 89 +++ crates/evm-node/src/rpc/get_certificate.rs | 26 +- crates/evm-node/src/rpc/openapi/openapi.yaml | 18 +- crates/evm-node/src/rpc_middleware.rs | 359 +++++++++- crates/evm/src/subcall.rs | 24 + crates/execution-config/src/chainspec.rs | 21 +- crates/execution-config/src/hardforks.rs | 29 +- .../tests/hardfork_transition.rs | 9 +- crates/execution-validation/src/consensus.rs | 57 ++ crates/malachite-app/METRICS.md | 32 + crates/malachite-app/src/app.rs | 285 ++++++-- .../src/handlers/started_round.rs | 151 ++++ crates/malachite-app/src/metrics/app.rs | 158 +++++ crates/malachite-app/src/node.rs | 8 +- crates/malachite-app/src/request.rs | 18 +- crates/malachite-app/src/rpc/handlers.rs | 13 +- crates/malachite-app/src/rpc/mod.rs | 1 + crates/malachite-app/src/rpc/routes.rs | 53 +- crates/malachite-app/src/rpc/types.rs | 54 ++ crates/malachite-app/src/state.rs | 145 ++-- crates/malachite-app/tests/rpc_integration.rs | 4 +- crates/precompiles/src/call_from.rs | 153 ++++ crates/precompiles/src/helpers.rs | 55 +- .../precompiles/src/native_coin_authority.rs | 11 + crates/precompiles/src/native_coin_control.rs | 259 +++++-- crates/precompiles/src/system_accounting.rs | 246 ++++--- crates/quake/scenarios/nightly-upgrade.toml | 2 +- crates/quake/templates/local/compose.yaml.hbs | 4 +- crates/types/src/commit_http.rs | 27 + .../config-blockscout/frontend/.curlrc | 2 +- docs/adr/0004-base-fee-validation.md | 31 +- scripts/create-release-tag.sh | 670 ++++++++++++++++++ scripts/genesis/genesis.ts | 12 +- scripts/hardhat/tasks/genesis.ts | 13 +- scripts/release-config.sh | 22 +- scripts/release-package.sh | 3 +- scripts/release-refs.sh | 87 +++ scripts/scenarios/nightly-chaos-testing.sh | 13 +- scripts/scenarios/nightly-random-manifests.sh | 13 +- scripts/scenarios/nightly-upgrade.sh | 2 +- 47 files changed, 2916 insertions(+), 396 deletions(-) create mode 100755 scripts/create-release-tag.sh create mode 100755 scripts/release-refs.sh diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 478e9f4..ad465a9 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -33,4 +33,24 @@ ignore = [ # Transitive dependency via reth-dns-discovery (reth v1.11.3) and libp2p-mdns # (libp2p 0.56.0) — both pin hickory-proto to 0.25.x; cannot update without bumping reth. "RUSTSEC-2026-0119", + + # core2 0.4.0 is unmaintained and all versions yanked; no replacement available. + # Transitive dependency via multihash 0.19.3 → multiaddr → libp2p 0.56.0. Cannot update + # without bumping the malachite/libp2p dependency chain. + "RUSTSEC-2026-0105", + + # proc-macro-error2 2.0.1 is unmaintained; no newer version available in the 2.x series. + # Transitive dependency via aquamarine → reth-transaction-pool (reth v1.11.3). + # Cannot update without bumping reth. + "RUSTSEC-2026-0173", + + # git2 0.20.4 - potential UB in Remote::list(); no patched version available. + # Transitive dependency via vergen-git2 9.1.0 → reth-node-core (reth v1.11.3). + # Cannot update without bumping reth. + "RUSTSEC-2026-0183", + + # git2 0.20.4 - potential UB with Signature from buffer-created BlameHunk; no patched version. + # Transitive dependency via vergen-git2 9.1.0 → reth-node-core (reth v1.11.3). + # Cannot update without bumping reth. + "RUSTSEC-2026-0184", ] \ No newline at end of file diff --git a/.gitignore b/.gitignore index 52df018..8e93725 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,10 @@ node_modules/ .env.test.local .env.production.local .env.contract-ops +/public-release.env +/public-sync.env +/release.env +/release-refs.env # Rust-specific **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 47d7021..e605350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1211,6 +1211,7 @@ name = "arc-evm-node" version = "0.0.1" dependencies = [ "alloy-consensus", + "alloy-eips", "alloy-network", "alloy-primitives", "alloy-rpc-types-engine", @@ -1448,7 +1449,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-app" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-codec", "arc-malachitebft-config", @@ -1476,7 +1477,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-app-channel" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-app", "arc-malachitebft-config", @@ -1494,7 +1495,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-codec" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "bytes", ] @@ -1502,7 +1503,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-config" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "bytesize", @@ -1516,7 +1517,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-consensus" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-driver", "arc-malachitebft-core-types", @@ -1536,7 +1537,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-driver" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-state-machine", "arc-malachitebft-core-types", @@ -1549,7 +1550,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-state-machine" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "derive-where", @@ -1559,7 +1560,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-types" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-peer", "async-trait", @@ -1572,7 +1573,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-votekeeper" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "derive-where", @@ -1583,7 +1584,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-discovery" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-metrics", "either", @@ -1598,7 +1599,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-engine" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-codec", "arc-malachitebft-config", @@ -1631,7 +1632,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-metrics" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-state-machine", "prometheus-client", @@ -1640,7 +1641,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-network" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-discovery", "arc-malachitebft-metrics", @@ -1669,7 +1670,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-peer" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "bs58", "multihash", @@ -1680,7 +1681,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-proto" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "prost 0.13.5", "prost-types 0.13.5", @@ -1690,7 +1691,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-signing" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "async-trait", @@ -1700,7 +1701,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-signing-ed25519" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "base64 0.22.1", @@ -1713,7 +1714,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-sync" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "arc-malachitebft-metrics", @@ -1735,7 +1736,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-wal" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "advisory-lock", "bytes", @@ -4221,7 +4222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -4493,7 +4494,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4798,7 +4799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7407,9 +7408,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -7856,7 +7857,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9075,7 +9076,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", "heck", - "itertools 0.12.1", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -9095,7 +9096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.12.1", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -9115,7 +9116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -9128,7 +9129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -9141,7 +9142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -9343,9 +9344,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -9373,7 +9374,7 @@ dependencies = [ "once_cell", "socket2 0.6.2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -12992,7 +12993,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -14132,7 +14133,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -15465,7 +15466,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f4e7ac8..3ab5cab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -269,67 +269,67 @@ wiremock = "0.6" [workspace.dependencies.malachitebft-app] package = "arc-malachitebft-app" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-app-channel] package = "arc-malachitebft-app-channel" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-codec] package = "arc-malachitebft-codec" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-config] package = "arc-malachitebft-config" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-core-consensus] package = "arc-malachitebft-core-consensus" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-core-state-machine] package = "arc-malachitebft-core-state-machine" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-core-types] package = "arc-malachitebft-core-types" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-network] package = "arc-malachitebft-network" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-peer] package = "arc-malachitebft-peer" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-proto] package = "arc-malachitebft-proto" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-signing] package = "arc-malachitebft-signing" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-signing-ed25519] package = "arc-malachitebft-signing-ed25519" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-sync] package = "arc-malachitebft-sync" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [profile.dev] # enable basic optimizations (otherwise reth can't process txs) diff --git a/assets/localdev/genesis.json b/assets/localdev/genesis.json index 433c210..88cd647 100644 --- a/assets/localdev/genesis.json +++ b/assets/localdev/genesis.json @@ -26,6 +26,7 @@ "zero5Block": 0, "zero6Block": 0, "zero7Time": 0, + "zero8Time": 0, "osakaTime": 0 }, "nonce": "0x0", diff --git a/crates/evm-node/Cargo.toml b/crates/evm-node/Cargo.toml index becb2d7..43acd24 100644 --- a/crates/evm-node/Cargo.toml +++ b/crates/evm-node/Cargo.toml @@ -15,6 +15,7 @@ integration = ["arc-evm/integration"] [dependencies] alloy-consensus = { workspace = true, features = ["serde"] } +alloy-eips.workspace = true alloy-network.workspace = true alloy-primitives.workspace = true alloy-rpc-types-engine.workspace = true diff --git a/crates/evm-node/src/rpc/arc.rs b/crates/evm-node/src/rpc/arc.rs index 7991fa8..1b29147 100644 --- a/crates/evm-node/src/rpc/arc.rs +++ b/crates/evm-node/src/rpc/arc.rs @@ -16,7 +16,7 @@ //! Top level file of `reth` node API extension RPCs -use crate::rpc::common::ARC_DEFAULT_BASE_URL; +use crate::rpc::common::{HexOrDecimalU64, ARC_DEFAULT_BASE_URL}; use crate::rpc::get_certificate::{ rpc_get_certificate, CertificateSource, HttpCertificateSource, RpcCommitCertificate, }; @@ -35,7 +35,7 @@ pub trait ArcApi { fn version(&self) -> RpcResult; #[method(name = "getCertificate")] - async fn get_certificate(&self, height: u64) -> RpcResult; + async fn get_certificate(&self, height: HexOrDecimalU64) -> RpcResult; } pub struct ArcApiImpl { @@ -50,8 +50,8 @@ impl ArcApiImpl { #[async_trait] impl ArcApiServer for ArcApiImpl { - async fn get_certificate(&self, height: u64) -> RpcResult { - rpc_get_certificate(&self.certificate_source, height).await + async fn get_certificate(&self, height: HexOrDecimalU64) -> RpcResult { + rpc_get_certificate(&self.certificate_source, height.as_u64()).await } fn version(&self) -> RpcResult { rpc_get_version() diff --git a/crates/evm-node/src/rpc/common.rs b/crates/evm-node/src/rpc/common.rs index a6e8827..3f01326 100644 --- a/crates/evm-node/src/rpc/common.rs +++ b/crates/evm-node/src/rpc/common.rs @@ -15,9 +15,58 @@ // limitations under the License. use jsonrpsee::types::{ErrorCode, ErrorObjectOwned}; +use serde::de; +use std::fmt; pub const ARC_DEFAULT_BASE_URL: &str = "http://127.0.0.1:31000"; +/// A u64 that deserializes from either a JSON number or a hex quantity string (`"0x…"`). +/// Ethereum proxies (e.g. eRPC) rewrite numeric params to hex; this accepts both. +#[derive(Debug, Clone, Copy)] +pub struct HexOrDecimalU64(u64); + +impl HexOrDecimalU64 { + pub fn as_u64(self) -> u64 { + self.0 + } +} + +impl From for HexOrDecimalU64 { + fn from(v: u64) -> Self { + Self(v) + } +} + +impl<'de> de::Deserialize<'de> for HexOrDecimalU64 { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(HexOrDecimalU64Visitor) + } +} + +struct HexOrDecimalU64Visitor; + +impl<'de> de::Visitor<'de> for HexOrDecimalU64Visitor { + type Value = HexOrDecimalU64; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a u64 or hex-encoded quantity string") + } + + fn visit_u64(self, v: u64) -> Result { + Ok(HexOrDecimalU64(v)) + } + + fn visit_str(self, v: &str) -> Result { + let hex = v + .strip_prefix("0x") + .or_else(|| v.strip_prefix("0X")) + .ok_or_else(|| de::Error::custom("hex string must start with 0x"))?; + u64::from_str_radix(hex, 16) + .map(HexOrDecimalU64) + .map_err(de::Error::custom) + } +} + pub fn invalid_params(msg: impl Into) -> ErrorObjectOwned { ErrorObjectOwned::owned(ErrorCode::InvalidParams.code(), msg.into(), None::<()>) } @@ -28,3 +77,43 @@ pub mod codes { /// Upstream service unreachable (TCP connect failures). pub const UPSTREAM_UNREACHABLE: i32 = -32005; } + +#[cfg(test)] +mod tests { + use super::*; + + fn deser(json: &str) -> Result { + serde_json::from_str(json) + } + + #[test] + fn accepts_decimal_number() { + assert_eq!(deser("123").unwrap().as_u64(), 123); + } + + #[test] + fn accepts_hex_string() { + assert_eq!(deser(r#""0x7b""#).unwrap().as_u64(), 123); + } + + #[test] + fn accepts_hex_uppercase_prefix() { + assert_eq!(deser(r#""0X7B""#).unwrap().as_u64(), 123); + } + + #[test] + fn accepts_zero() { + assert_eq!(deser("0").unwrap().as_u64(), 0); + assert_eq!(deser(r#""0x0""#).unwrap().as_u64(), 0); + } + + #[test] + fn rejects_non_hex_string() { + assert!(deser(r#""abc""#).is_err()); + } + + #[test] + fn rejects_empty_hex() { + assert!(deser(r#""0x""#).is_err()); + } +} diff --git a/crates/evm-node/src/rpc/get_certificate.rs b/crates/evm-node/src/rpc/get_certificate.rs index 901f536..6eb36ed 100644 --- a/crates/evm-node/src/rpc/get_certificate.rs +++ b/crates/evm-node/src/rpc/get_certificate.rs @@ -197,7 +197,9 @@ mod tests { #[tokio::test] async fn rejects_invalid_height() { let api = ArcApiImpl::new(MockCertSource); - let err = ArcApiServer::get_certificate(&api, 0).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 0.into()) + .await + .unwrap_err(); assert!(err.message().contains("height must be")); } @@ -211,7 +213,9 @@ mod tests { } } let api = ArcApiImpl::new(EmptySource); - let err = ArcApiServer::get_certificate(&api, 5).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 5.into()) + .await + .unwrap_err(); assert_eq!(err.code(), NOT_FOUND); } struct ConnectFail; @@ -225,7 +229,9 @@ mod tests { #[tokio::test] async fn connect_failure_maps_to_custom_code() { let api = ArcApiImpl::new(ConnectFail); - let err = ArcApiServer::get_certificate(&api, 10).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 10.into()) + .await + .unwrap_err(); assert_eq!(err.code(), UPSTREAM_UNREACHABLE); assert!(err.message().contains("unreachable")); } @@ -233,7 +239,7 @@ mod tests { #[tokio::test] async fn returns_real_cert_from_source() { let api = ArcApiImpl::new(MockCertSource); - let cert = ArcApiServer::get_certificate(&api, 7).await.unwrap(); + let cert = ArcApiServer::get_certificate(&api, 7.into()).await.unwrap(); assert_eq!(cert.height, 7); assert_eq!(cert.round, 3); } @@ -248,7 +254,9 @@ mod tests { #[tokio::test] async fn timeout_maps_to_internal_error() { let api = ArcApiImpl::new(TimeoutSource); - let err = ArcApiServer::get_certificate(&api, 9).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 9.into()) + .await + .unwrap_err(); assert_eq!(err.code(), ErrorCode::InternalError.code()); assert!(err.message().contains("Timeout")); } @@ -264,7 +272,9 @@ mod tests { #[tokio::test] async fn decode_error_maps_to_internal_error() { let api = ArcApiImpl::new(DecodeFailSource); - let err = ArcApiServer::get_certificate(&api, 11).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 11.into()) + .await + .unwrap_err(); assert_eq!(err.code(), ErrorCode::InternalError.code()); assert!(err.message().contains("Failed to decode")); } @@ -280,7 +290,9 @@ mod tests { #[tokio::test] async fn network_status_maps_to_internal_error() { let api = ArcApiImpl::new(NetworkFailSource); - let err = ArcApiServer::get_certificate(&api, 13).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 13.into()) + .await + .unwrap_err(); assert_eq!(err.code(), ErrorCode::InternalError.code()); assert!(err.message().contains("Upstream HTTP error")); assert!(err.message().contains("500")); diff --git a/crates/evm-node/src/rpc/openapi/openapi.yaml b/crates/evm-node/src/rpc/openapi/openapi.yaml index 411cc77..8fb35c3 100644 --- a/crates/evm-node/src/rpc/openapi/openapi.yaml +++ b/crates/evm-node/src/rpc/openapi/openapi.yaml @@ -40,17 +40,21 @@ paths: description: | Single JSON-RPC 2.0 POST endpoint. Methods currently implemented under the `arc` namespace: - 1. arc_getCertificate(height: u64) -> RpcCommitCertificate + 1. arc_getCertificate(height: u64 | hex string) -> RpcCommitCertificate Returns the consensus commit certificate for the specified height if available. + The height parameter accepts either a decimal number (7) or a hex quantity string ("0x7"). Error Mapping (error.code -> meaning): -32602 Invalid params (height < 1) -32004 Not found (no certificate upstream) -32005 Upstream unreachable (connect errors) -32603 Internal (timeout / decode / generic upstream HTTP status) - Curl Example: + Curl Examples: curl -s -X POST -H 'Content-Type: application/json' \ --data '{"jsonrpc":"2.0","method":"arc_getCertificate","params":[7],"id":1}' \ http://127.0.0.1:8545 + curl -s -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"arc_getCertificate","params":["0x7"],"id":1}' \ + http://127.0.0.1:8545 2. arc_getVersion() -> RpcVersionInfo Returns version/build information for the node execution layer. @@ -108,9 +112,13 @@ components: minItems: 1 maxItems: 1 items: - type: integer - minimum: 1 - format: int64 + oneOf: + - type: integer + minimum: 1 + format: int64 + - type: string + pattern: "^0[xX][0-9a-fA-F]+$" + description: Hex-encoded quantity (e.g. "0x7"). Must be >= 1. id: oneOf: - type: integer diff --git a/crates/evm-node/src/rpc_middleware.rs b/crates/evm-node/src/rpc_middleware.rs index 0491e3b..069796a 100644 --- a/crates/evm-node/src/rpc_middleware.rs +++ b/crates/evm-node/src/rpc_middleware.rs @@ -15,13 +15,15 @@ // limitations under the License. use alloy_consensus::TxEnvelope; +use alloy_eips::{BlockId, BlockNumberOrTag}; use alloy_network::eip2718::Decodable2718; use alloy_primitives::Bytes; use jsonrpsee::{ core::middleware::{layer::Either, Batch, BatchEntry, Notification, RpcServiceT}, - types::{ErrorObject, ErrorObjectOwned, Id, Request, ResponsePayload}, + types::{ErrorObject, ErrorObjectOwned, Id, Params, Request, ResponsePayload}, BatchResponseBuilder, MethodResponse, }; +use serde::de::DeserializeOwned; use std::future::Future; use tower::Layer; @@ -29,8 +31,22 @@ const ETH_SUBSCRIBE_METHOD: &str = "eth_subscribe"; const PENDING_TX_SUBSCRIPTION_TYPE: &str = "newPendingTransactions"; const ETH_NEW_PENDING_TX_FILTER_METHOD: &str = "eth_newPendingTransactionFilter"; const ETH_GET_BLOCK_BY_NUMBER_METHOD: &str = "eth_getBlockByNumber"; -const PENDING_BLOCK_TAG: &str = "pending"; +const BLOCK_NUMBER_OBJECT_KEY: &str = "number"; +const BLOCK_ID_OBJECT_KEY_SNAKE: &str = "block_id"; +const BLOCK_ID_OBJECT_KEY_CAMEL: &str = "blockId"; const ETH_GET_TX_BY_SENDER_AND_NONCE_METHOD: &str = "eth_getTransactionBySenderAndNonce"; +const ETH_GET_BLOCK_RECEIPTS_METHOD: &str = "eth_getBlockReceipts"; +const ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD: &str = "eth_getBlockTransactionCountByNumber"; +const ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD: &str = "eth_getTransactionByBlockNumberAndIndex"; +const ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD: &str = + "eth_getRawTransactionByBlockNumberAndIndex"; +const ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD: &str = "eth_getUncleCountByBlockNumber"; +const ETH_GET_HEADER_BY_NUMBER_METHOD: &str = "eth_getHeaderByNumber"; +// jsonrpsee proc-macro field name for eth_getHeaderByNumber's BlockNumberOrTag param. +// Reth's trait declares `hash: BlockNumberOrTag` (copy-paste from getHeaderByHash) — +// see reth-rpc-eth-api/src/core.rs. If that arg is ever renamed, update this key and +// the object-form test cases; the array/positional form is unaffected. +const BLOCK_HEADER_NUMBER_OBJECT_KEY: &str = "hash"; const ETH_SEND_RAW_TRANSACTION_METHOD: &str = "eth_sendRawTransaction"; const ETH_SEND_RAW_TRANSACTION_SYNC_METHOD: &str = "eth_sendRawTransactionSync"; const PENDING_TX_SUBSCRIPTION_ERROR_CODE: i32 = -32001; @@ -45,9 +61,13 @@ pub const ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT: usize = 100; /// Config for the Arc-specific RPC middleware stack. #[derive(Clone, Debug)] pub struct ArcRpcLayer { - /// When true (default), `eth_subscribe("newPendingTransactions")`, - /// `eth_newPendingTransactionFilter`, `eth_getBlockByNumber("pending")`, - /// and `eth_getTransactionBySenderAndNonce` are blocked. + /// When true (default), blocks all RPC methods that can expose pending-block + /// state: `eth_subscribe("newPendingTransactions")`, `eth_newPendingTransactionFilter`, + /// `eth_getTransactionBySenderAndNonce`, and the block-content methods + /// `eth_getBlockByNumber`, `eth_getBlockReceipts`, + /// `eth_getBlockTransactionCountByNumber`, `eth_getTransactionByBlockNumberAndIndex`, + /// `eth_getRawTransactionByBlockNumberAndIndex`, `eth_getUncleCountByBlockNumber`, + /// and `eth_getHeaderByNumber` when called with the `"pending"` tag. /// When false, the filter is bypassed and these are allowed. /// CLI users opt out of the default via `--arc.expose-pending-txs`. pub filter_pending_txs: bool, @@ -449,19 +469,59 @@ fn is_pool_pending_tx_lookup(req: &Request<'_>) -> bool { req.method_name() == ETH_GET_TX_BY_SENDER_AND_NONCE_METHOD } -/// Returns true if the request is `eth_getBlockByNumber("pending", ...)`. +fn is_pending_block_method(method: &str) -> bool { + method == ETH_GET_BLOCK_BY_NUMBER_METHOD + || method == ETH_GET_BLOCK_RECEIPTS_METHOD + || method == ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD + || method == ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD + || method == ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD + || method == ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD + || method == ETH_GET_HEADER_BY_NUMBER_METHOD +} + +/// Returns true if the request queries pending-block state via a block number/tag parameter. /// /// The consensus engine may briefly expose a pending block via `provider().pending_block()` /// even when `--rpc.pending-block=none` is set. Intercepting at the middleware layer /// guarantees a consistent `null` response regardless of consensus-engine state. fn is_pending_block_query(req: &Request<'_>) -> bool { - if req.method_name() != ETH_GET_BLOCK_BY_NUMBER_METHOD { + if !is_pending_block_method(req.method_name()) { return false; } - if let Ok(Some(block_tag)) = req.params().sequence().optional_next::() { - return block_tag == PENDING_BLOCK_TAG; + let params = req.params(); + // eth_getBlockReceipts accepts BlockId: handles string tags and EIP-1898 object form + // ({"blockNumber": "pending"}). All other methods accept BlockNumberOrTag. + if req.method_name() == ETH_GET_BLOCK_RECEIPTS_METHOD { + return extract_param::( + params, + &[BLOCK_ID_OBJECT_KEY_SNAKE, BLOCK_ID_OBJECT_KEY_CAMEL], + ) + .is_some_and(|id| id.is_pending()); + } + // Object key for named params — coupled to jsonrpsee proc-macro field names. + let key = if req.method_name() == ETH_GET_HEADER_BY_NUMBER_METHOD { + BLOCK_HEADER_NUMBER_OBJECT_KEY + } else { + BLOCK_NUMBER_OBJECT_KEY + }; + extract_param::(params, &[key]).is_some_and(|t| t.is_pending()) +} + +/// Extracts and deserializes the first positional or named RPC param. +/// +/// For object-style params, tries each key in `keys` in order. For array-style +/// params, reads the first element. Returns `None` on missing field or parse failure. +/// +/// `BlockNumberOrTag` deserialization lowercases tags internally, so case variants +/// ("Pending", "PENDING") deserialize correctly without extra normalization. +fn extract_param(params: Params<'_>, keys: &[&str]) -> Option { + if params.is_object() { + let obj = params.parse::().ok()?; + let val = keys.iter().find_map(|k| obj.get(*k))?.clone(); + serde_json::from_value(val).ok() + } else { + params.sequence().optional_next::().ok().flatten() } - false } /// Builds a JSON-RPC success response containing `null`. @@ -859,6 +919,32 @@ mod tests { ); } + #[tokio::test] + async fn test_enabled_batch_new_method_pending_returns_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let batch = Batch::from(vec![ + Ok(BatchEntry::Call(create_request_with_params( + "eth_blockNumber", + RawValue::from_string("[]".to_string()).unwrap(), + 1, + ))), + Ok(BatchEntry::Call(create_request_with_params( + ETH_GET_BLOCK_RECEIPTS_METHOD, + RawValue::from_string(r#"["pending"]"#.to_string()).unwrap(), + 2, + ))), + ]); + let response = middleware.batch(batch).await; + let json = response.into_json(); + let responses: Vec = serde_json::from_str(json.get()).unwrap(); + assert_eq!(responses.len(), 2); + assert_eq!(responses[0]["result"], "success"); + assert!( + responses[1]["result"].is_null(), + "batch new-method pending should return null" + ); + } + #[tokio::test] async fn test_enabled_batch_pool_lookup_returns_null() { let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); @@ -1070,6 +1156,36 @@ mod tests { ); } + #[tokio::test] + async fn test_disabled_allows_new_pending_block_methods() { + let layer = ArcRpcLayer { + filter_pending_txs: false, + ..Default::default() + }; + let middleware = layer.layer(MockRpcService); + let methods = [ + ETH_GET_BLOCK_RECEIPTS_METHOD, + ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, + ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, + ]; + for method in methods { + let params = RawValue::from_string(r#"["pending"]"#.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "filter_pending_txs=false should allow {method}" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "filter_pending_txs=false should forward {method}" + ); + } + } + // ── ArcRpcLayer::default() ────────────────────────────────────────── #[test] @@ -1476,4 +1592,227 @@ mod tests { let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); assert_eq!(json["result"], "success"); } + + // ── Case-insensitive "pending" tag + additional block-content methods ── + // + // All six block-content methods accept a block number/tag as first param. + // When that tag is "pending" (any case) the middleware returns null. + + #[tokio::test] + async fn test_pending_block_methods_all_tags_return_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases = [ + (ETH_GET_BLOCK_BY_NUMBER_METHOD, "pending"), + (ETH_GET_BLOCK_BY_NUMBER_METHOD, "Pending"), + (ETH_GET_BLOCK_BY_NUMBER_METHOD, "PENDING"), + (ETH_GET_BLOCK_RECEIPTS_METHOD, "pending"), + (ETH_GET_BLOCK_RECEIPTS_METHOD, "Pending"), + (ETH_GET_BLOCK_RECEIPTS_METHOD, "PENDING"), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, "pending"), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, "Pending"), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, "PENDING"), + (ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "pending"), + (ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "Pending"), + (ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "PENDING"), + (ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "pending"), + (ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "Pending"), + (ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "PENDING"), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, "pending"), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, "Pending"), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, "PENDING"), + (ETH_GET_HEADER_BY_NUMBER_METHOD, "pending"), + (ETH_GET_HEADER_BY_NUMBER_METHOD, "Pending"), + (ETH_GET_HEADER_BY_NUMBER_METHOD, "PENDING"), + ]; + for (method, tag) in cases { + let params = RawValue::from_string(format!("[\"{tag}\"]")).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "{method} with \"{tag}\" should not error" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert!( + json["result"].is_null(), + "{method} with \"{tag}\" should return null" + ); + } + } + + #[tokio::test] + async fn test_additional_pending_block_methods_non_pending_tag_passes_through() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[(&str, &str)] = &[ + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"["latest"]"#), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, r#"["latest"]"#), + ( + ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"["0x1", "0x0"]"#, + ), + ( + ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"["0x1", "0x0"]"#, + ), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, r#"["latest"]"#), + (ETH_GET_HEADER_BY_NUMBER_METHOD, r#"["latest"]"#), + ]; + for (method, params_json) in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + + assert!( + response.as_error_code().is_none(), + "{method} with non-pending tag should pass through" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "{method} with non-pending tag should return inner service response" + ); + } + } + + // ── Object-style params bypass ────────────────────────────────────── + // + // jsonrpsee handlers accept both array and object params. The middleware + // must intercept object-style pending-block queries too. + + #[tokio::test] + async fn test_object_params_pending_block_returns_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[(&str, &str)] = &[ + // Five methods use "number" key + ( + ETH_GET_BLOCK_BY_NUMBER_METHOD, + r#"{"number": "pending", "full": false}"#, + ), + ( + ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, + r#"{"number": "pending"}"#, + ), + ( + ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"{"number": "pending", "index": "0x0"}"#, + ), + ( + ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"{"number": "pending", "index": "0x0"}"#, + ), + ( + ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, + r#"{"number": "pending"}"#, + ), + // eth_getBlockReceipts uses "block_id" (snake) or "blockId" (camel) + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"{"block_id": "pending"}"#), + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"{"blockId": "pending"}"#), + // eth_getHeaderByNumber uses "hash" (Reth's proc-macro arg name) + (ETH_GET_HEADER_BY_NUMBER_METHOD, r#"{"hash": "pending"}"#), + // Case-insensitive variant via object params — guards that BlockNumberOrTag deserialization lowercases tags + ( + ETH_GET_BLOCK_BY_NUMBER_METHOD, + r#"{"number": "PENDING", "full": false}"#, + ), + ]; + for (method, params_json) in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "{method} with object params should not error" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert!( + json["result"].is_null(), + "{method} with object params {{...\"pending\"...}} should return null" + ); + } + } + + #[tokio::test] + async fn test_object_params_non_pending_passes_through() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[(&str, &str)] = &[ + ( + ETH_GET_BLOCK_BY_NUMBER_METHOD, + r#"{"number": "0x1", "full": false}"#, + ), + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"{"block_id": "latest"}"#), + (ETH_GET_HEADER_BY_NUMBER_METHOD, r#"{"hash": "latest"}"#), + // Unknown field → passes through (Reth returns parse error, not middleware) + (ETH_GET_BLOCK_BY_NUMBER_METHOD, r#"{"garbage": "pending"}"#), + ]; + for (method, params_json) in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "{method} with non-pending object params should pass through" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "{method} with non-pending object params should reach inner service" + ); + } + } + + // ── EIP-1898 BlockId object form ──────────────────────────────────── + // + // eth_getBlockReceipts accepts BlockId, which allows an EIP-1898 object + // form in addition to the plain string form. {"blockNumber": "pending"} + // deserializes to BlockId::Number(Pending) and must also be intercepted. + + #[tokio::test] + async fn test_eip1898_block_receipts_pending_returns_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[&str] = &[ + // Array-style: element is an EIP-1898 object + r#"[{"blockNumber": "pending"}]"#, + // Named params (snake_case key) with EIP-1898 value + r#"{"block_id": {"blockNumber": "pending"}}"#, + // Named params (camelCase key) with EIP-1898 value + r#"{"blockId": {"blockNumber": "pending"}}"#, + ]; + for params_json in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(ETH_GET_BLOCK_RECEIPTS_METHOD, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "eth_getBlockReceipts EIP-1898 pending should not error" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert!( + json["result"].is_null(), + "eth_getBlockReceipts with EIP-1898 pending ({params_json}) should return null" + ); + } + } + + #[tokio::test] + async fn test_eip1898_block_receipts_non_pending_passes_through() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[&str] = &[ + r#"[{"blockNumber": "0x1"}]"#, + r#"{"block_id": {"blockNumber": "0x1"}}"#, + ]; + for params_json in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(ETH_GET_BLOCK_RECEIPTS_METHOD, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "eth_getBlockReceipts EIP-1898 non-pending should pass through" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "eth_getBlockReceipts with EIP-1898 non-pending ({params_json}) should reach inner service" + ); + } + } } diff --git a/crates/evm/src/subcall.rs b/crates/evm/src/subcall.rs index ccea548..fbf7ea3 100644 --- a/crates/evm/src/subcall.rs +++ b/crates/evm/src/subcall.rs @@ -131,3 +131,27 @@ impl std::fmt::Debug for SubcallRegistry { .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + + #[test] + fn allowed_callers_unrestricted_allows_any_address() { + let callers = AllowedCallers::Unrestricted; + assert!(callers.is_allowed(&address!("0000000000000000000000000000000000000001"))); + assert!(callers.is_allowed(&Address::ZERO)); + } + + #[test] + fn allowed_callers_only_allows_listed_rejects_others() { + let allowed = address!("1800000000000000000000000000000000000005"); + let callers = AllowedCallers::Only(HashSet::from([allowed])); + assert!(callers.is_allowed(&allowed)); + assert!(!callers.is_allowed(&address!("dead00000000000000000000000000000000beef"))); + // Empty allowlist rejects every caller. + let empty = AllowedCallers::Only(HashSet::new()); + assert!(!empty.is_allowed(&allowed)); + } +} diff --git a/crates/execution-config/src/chainspec.rs b/crates/execution-config/src/chainspec.rs index ba1c6d9..2f236cc 100644 --- a/crates/execution-config/src/chainspec.rs +++ b/crates/execution-config/src/chainspec.rs @@ -397,6 +397,9 @@ pub fn localdev_with_hardforks(hardforks: &[(ArcHardfork, ForkCondition)]) -> Ar ArcHardfork::Zero7 => inner .hardforks .insert(ArcHardfork::Zero7.boxed(), condition), + ArcHardfork::Zero8 => inner + .hardforks + .insert(ArcHardfork::Zero8.boxed(), condition), }; } @@ -951,7 +954,7 @@ mod tests { .expect("Failed to parse arc-localdev"); assert_eq!(spec.chain().id(), LOCALDEV_CHAIN_ID); assert_arc_chainspec_evm_hardforks(&spec); - assert_eq!(spec.forks_iter().count(), 23); + assert_eq!(spec.forks_iter().count(), 24); assert!(spec.is_osaka_active_at_timestamp(0)); // verify zero3 hardfork block @@ -980,12 +983,18 @@ mod tests { spec.is_fork_active_at_timestamp(ArcHardfork::Zero7, 0), "Zero7 should be active at timestamp 0 in hardfork.rs, and load by chainspec" ); + // Zero8 activates by timestamp (Arc convention from Zero7 onward). + assert!( + spec.is_fork_active_at_timestamp(ArcHardfork::Zero8, 0), + "Zero8 should be active at timestamp 0 in hardfork.rs, and load by chainspec" + ); let flags = spec.get_hardfork_flags(0, 0); assert!(flags.is_active(ArcHardfork::Zero3)); assert!(flags.is_active(ArcHardfork::Zero4)); assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(flags.is_active(ArcHardfork::Zero7)); + assert!(flags.is_active(ArcHardfork::Zero8)); } #[test] @@ -994,7 +1003,7 @@ mod tests { assert_eq!(spec.chain().id(), LOCALDEV_CHAIN_ID); assert_arc_chainspec_evm_hardforks(&spec); assert!(spec.is_osaka_active_at_timestamp(0)); - assert_eq!(spec.forks_iter().count(), 23); + assert_eq!(spec.forks_iter().count(), 24); // verify zero3 hardfork block assert!(!spec.is_fork_active_at_timestamp(ArcHardfork::Zero3, 1762732800)); @@ -1011,12 +1020,16 @@ mod tests { // Zero7 activates by timestamp (Arc convention from Zero7 onward). assert!(spec.is_fork_active_at_timestamp(ArcHardfork::Zero7, 0)); assert!(!spec.is_fork_active_at_block(ArcHardfork::Zero7, 0)); + // Zero8 activates by timestamp (Arc convention from Zero7 onward). + assert!(spec.is_fork_active_at_timestamp(ArcHardfork::Zero8, 0)); + assert!(!spec.is_fork_active_at_block(ArcHardfork::Zero8, 0)); let flags = spec.get_hardfork_flags(0, 0); assert!(flags.is_active(ArcHardfork::Zero3)); assert!(flags.is_active(ArcHardfork::Zero4)); assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(flags.is_active(ArcHardfork::Zero7)); + assert!(flags.is_active(ArcHardfork::Zero8)); assert_eq!( spec.display_hardforks().to_string(), r#"Pre-merge hard forks (block based): @@ -1044,7 +1057,8 @@ Post-merge hard forks (timestamp based): - Cancun @0 blob: (target: 6, max: 9, fraction: 5007716) - Prague @0 blob: (target: 6, max: 9, fraction: 5007716) - Osaka @0 blob: (target: 6, max: 9, fraction: 5007716) -- Zero7 @0 blob: (target: 6, max: 9, fraction: 5007716)"# +- Zero7 @0 blob: (target: 6, max: 9, fraction: 5007716) +- Zero8 @0 blob: (target: 6, max: 9, fraction: 5007716)"# ); } @@ -1167,6 +1181,7 @@ Post-merge hard forks (timestamp based): (ArcHardfork::Zero5, true), (ArcHardfork::Zero6, true), (ArcHardfork::Zero7, false), // deferred — not active at launch + (ArcHardfork::Zero8, false), // deferred — not active at launch ]; let paths: [(&str, &ArcChainSpec); 3] = [ ("parser", &from_parser), diff --git a/crates/execution-config/src/hardforks.rs b/crates/execution-config/src/hardforks.rs index 71fbd08..81077a9 100644 --- a/crates/execution-config/src/hardforks.rs +++ b/crates/execution-config/src/hardforks.rs @@ -33,6 +33,7 @@ hardfork!( #[default] Zero6, // v0.6 hardfork Zero7, // v0.7 hardfork — batch (Multicall3From) and memo contracts + Zero8, // v0.8 hardfork — delegatecall rejections charge the uniform early-revert penalty } ); @@ -54,6 +55,8 @@ pub struct ArcGenesisInfo { /// Built-in network schedules are defined in `ARC_*_HARDFORKS` and may activate /// earlier Arc hardforks by timestamp, e.g. testnet Zero5/Zero6. pub zero_7_time: Option, + /// v0.8 hardfork timestamp for genesis-configured chains. + pub zero_8_time: Option, } impl ArcGenesisInfo { @@ -78,6 +81,9 @@ impl ArcGenesisInfo { if let Some(time) = self.zero_7_time { hardforks.push((ArcHardfork::Zero7, ForkCondition::Timestamp(time))); } + if let Some(time) = self.zero_8_time { + hardforks.push((ArcHardfork::Zero8, ForkCondition::Timestamp(time))); + } hardforks } } @@ -101,6 +107,7 @@ pub struct ArcHardforkFlags { zero5: bool, zero6: bool, zero7: bool, + zero8: bool, } impl ArcHardforkFlags { @@ -111,6 +118,7 @@ impl ArcHardforkFlags { ArcHardfork::Zero5, ArcHardfork::Zero6, ArcHardfork::Zero7, + ArcHardfork::Zero8, ]; /// Check if a specific hardfork is active. @@ -121,6 +129,7 @@ impl ArcHardforkFlags { ArcHardfork::Zero5 => self.zero5, ArcHardfork::Zero6 => self.zero6, ArcHardfork::Zero7 => self.zero7, + ArcHardfork::Zero8 => self.zero8, } } @@ -132,6 +141,7 @@ impl ArcHardforkFlags { ArcHardfork::Zero5 => self.zero5 = value, ArcHardfork::Zero6 => self.zero6 = value, ArcHardfork::Zero7 => self.zero7 = value, + ArcHardfork::Zero8 => self.zero8 = value, } } @@ -278,6 +288,7 @@ pub static ARC_LOCALDEV_HARDFORKS: LazyLock = LazyLock::new(|| { // mixed-version peers (block-based forks declared after a timestamp fork break // ForkFilter's BTreeMap ordering). forks.insert(ArcHardfork::Zero7.boxed(), ForkCondition::Timestamp(0)); + forks.insert(ArcHardfork::Zero8.boxed(), ForkCondition::Timestamp(0)); forks }); @@ -389,6 +400,7 @@ mod tests { assert_eq!(ArcHardfork::Zero5.name(), "Zero5"); assert_eq!(ArcHardfork::Zero6.name(), "Zero6"); assert_eq!(ArcHardfork::Zero7.name(), "Zero7"); + assert_eq!(ArcHardfork::Zero8.name(), "Zero8"); } #[test] @@ -400,6 +412,7 @@ mod tests { assert!(!flags.is_active(ArcHardfork::Zero5)); assert!(!flags.is_active(ArcHardfork::Zero6)); assert!(!flags.is_active(ArcHardfork::Zero7)); + assert!(!flags.is_active(ArcHardfork::Zero8)); // Test from chain hardforks - localdev has all Arc hardforks active at genesis let flags = ArcHardforkFlags::from_chain_hardforks(&ARC_LOCALDEV_HARDFORKS, 0, 0); @@ -408,6 +421,7 @@ mod tests { assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(flags.is_active(ArcHardfork::Zero7)); + assert!(flags.is_active(ArcHardfork::Zero8)); // Test from chain hardforks - devnet has Zero3 and Zero4 active after their activation blocks let flags = ArcHardforkFlags::from_chain_hardforks( @@ -420,6 +434,7 @@ mod tests { assert!(!flags.is_active(ArcHardfork::Zero5)); assert!(!flags.is_active(ArcHardfork::Zero6)); assert!(!flags.is_active(ArcHardfork::Zero7)); + assert!(!flags.is_active(ArcHardfork::Zero8)); // Test from chain hardforks - devnet before Zero4 activation let flags = ArcHardforkFlags::from_chain_hardforks( @@ -456,9 +471,9 @@ mod tests { assert!(!flags.is_active(ArcHardfork::Zero3)); assert!(!flags.is_active(ArcHardfork::Zero4)); - // Test all_combinations() helper - should yield 32 combinations (2^5) + // Test all_combinations() helper - should yield 64 combinations (2^6) let combinations: Vec<_> = ArcHardforkFlags::all_combinations().collect(); - assert_eq!(combinations.len(), 32); + assert_eq!(combinations.len(), 64); // Verify some key combinations are present assert!(combinations.contains(&ArcHardforkFlags::with(&[]))); @@ -474,12 +489,13 @@ mod tests { ArcHardfork::Zero5, ArcHardfork::Zero6, ArcHardfork::Zero7, + ArcHardfork::Zero8, ]))); } #[test] fn test_parse_arc_hardfork_from_genesis() { - let s = r#"{ "config": { "zero3Block": 123123, "zero4Block": 223881, "zero5Block": 323496, "zero6Block": 423000, "zero7Time": 1800000000 } }"#; + let s = r#"{ "config": { "zero3Block": 123123, "zero4Block": 223881, "zero5Block": 323496, "zero6Block": 423000, "zero7Time": 1800000000, "zero8Time": 1900000000 } }"#; let genesis = serde_json::from_str::(s).expect("Failed to parse genesis"); let info = ArcGenesisInfo::extract_from(&genesis.config.extra_fields) @@ -489,6 +505,7 @@ mod tests { assert_eq!(info.zero_5_block, Some(323496)); assert_eq!(info.zero_6_block, Some(423000)); assert_eq!(info.zero_7_time, Some(1800000000)); + assert_eq!(info.zero_8_time, Some(1900000000)); } // Verify ethereum hardforks are supported for all networks. @@ -531,7 +548,7 @@ mod tests { fn test_arc_localdev_forks() { let forks = ARC_LOCALDEV_HARDFORKS.clone(); assert_base_hardforks(&forks); - assert_eq!(forks.len(), 23); + assert_eq!(forks.len(), 24); // verify hardfork zero3 block assert!(!forks.is_fork_active_at_timestamp(ArcHardfork::Zero3, 0)); @@ -552,6 +569,10 @@ mod tests { // Zero7 activates by timestamp (Arc convention from Zero7 onward). assert!(forks.is_fork_active_at_timestamp(ArcHardfork::Zero7, 0)); assert!(!forks.is_fork_active_at_block(ArcHardfork::Zero7, 0)); + + // Zero8 activates by timestamp (Arc convention from Zero7 onward). + assert!(forks.is_fork_active_at_timestamp(ArcHardfork::Zero8, 0)); + assert!(!forks.is_fork_active_at_block(ArcHardfork::Zero8, 0)); } #[test] diff --git a/crates/execution-e2e/tests/hardfork_transition.rs b/crates/execution-e2e/tests/hardfork_transition.rs index 576dbd4..5851642 100644 --- a/crates/execution-e2e/tests/hardfork_transition.rs +++ b/crates/execution-e2e/tests/hardfork_transition.rs @@ -17,7 +17,7 @@ //! Hardfork transition e2e tests for Arc Chain. //! //! These tests verify that block production works correctly across -//! hardfork boundaries for Zero4, Zero5, Zero6, and Zero7 hardforks. +//! hardfork boundaries for Zero4, Zero5, Zero6, Zero7, and Zero8 hardforks. use arc_execution_config::hardforks::ArcHardfork; use arc_execution_e2e::{ @@ -39,6 +39,7 @@ async fn test_hardfork_active_at_genesis() -> Result<()> { .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) .with_action(AssertHardfork::is_active(ArcHardfork::Zero6)) .with_action(AssertHardfork::is_active(ArcHardfork::Zero7)) + .with_action(AssertHardfork::is_active(ArcHardfork::Zero8)) .run() .await } @@ -54,6 +55,7 @@ async fn test_sequential_hardfork_transitions() -> Result<()> { (ArcHardfork::Zero5, ForkCondition::Block(6)), (ArcHardfork::Zero6, ForkCondition::Block(8)), (ArcHardfork::Zero7, ForkCondition::Block(10)), + (ArcHardfork::Zero8, ForkCondition::Block(12)), ]); ArcTestBuilder::new() @@ -64,6 +66,7 @@ async fn test_sequential_hardfork_transitions() -> Result<()> { .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero5)) .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero6)) .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero7)) + .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero8)) // Produce block 1-2 - Zero3 activates .with_action(ProduceBlocks::new(2)) .with_action(AssertBlockNumber::new(2)) @@ -84,6 +87,10 @@ async fn test_sequential_hardfork_transitions() -> Result<()> { .with_action(ProduceBlocks::new(2)) .with_action(AssertBlockNumber::new(10)) .with_action(AssertHardfork::is_active(ArcHardfork::Zero7)) + // Produce block 11-12 - Zero8 activates + .with_action(ProduceBlocks::new(2)) + .with_action(AssertBlockNumber::new(12)) + .with_action(AssertHardfork::is_active(ArcHardfork::Zero8)) .run() .await } diff --git a/crates/execution-validation/src/consensus.rs b/crates/execution-validation/src/consensus.rs index 9bafc95..3117058 100644 --- a/crates/execution-validation/src/consensus.rs +++ b/crates/execution-validation/src/consensus.rs @@ -1095,4 +1095,61 @@ mod tests { "Before Zero6, zero beneficiary should be allowed: {result:?}" ); } + + // Drives the public `HeaderValidator::validate_header` path so that + // accidentally removing the beneficiary check from `validate_header()` + // would be caught at the trait boundary, not just at the helper. + #[test] + fn validate_header_rejects_zero_beneficiary_at_zero6() { + use alloy_primitives::Address; + + // Activate Zero6 at genesis but defer Zero5 so the Zero5-gated + // extra_data/base_fee/gas_limit checks don't fail before reaching + // the beneficiary check. + let mut inner = ChainSpecBuilder::mainnet().build(); + inner + .hardforks + .insert(ArcHardfork::Zero5, ForkCondition::Block(100)); + inner + .hardforks + .insert(ArcHardfork::Zero6, ForkCondition::Block(0)); + let consensus = ArcConsensus::new(Arc::new(ArcChainSpec::new(inner))); + + let header = Header { + number: 1, + beneficiary: Address::ZERO, + ..Default::default() + }; + let sealed = SealedHeader::new(header.clone(), header.hash_slow()); + let result = + as HeaderValidator
>::validate_header(&consensus, &sealed); + assert!( + matches!(result, Err(ConsensusError::Other(_))), + "validate_header should reject zero beneficiary under Zero6: {result:?}" + ); + } + + #[test] + fn validate_header_accepts_zero_beneficiary_before_zero6() { + use alloy_primitives::Address; + + let mut inner = ChainSpecBuilder::mainnet().build(); + inner + .hardforks + .insert(ArcHardfork::Zero6, ForkCondition::Block(100)); + let consensus = ArcConsensus::new(Arc::new(ArcChainSpec::new(inner))); + + let header = Header { + number: 99, + beneficiary: Address::ZERO, + ..Default::default() + }; + let sealed = SealedHeader::new(header.clone(), header.hash_slow()); + let result = + as HeaderValidator
>::validate_header(&consensus, &sealed); + assert!( + result.is_ok(), + "validate_header should accept zero beneficiary before Zero6: {result:?}" + ); + } } diff --git a/crates/malachite-app/METRICS.md b/crates/malachite-app/METRICS.md index b73e0d4..1e380b7 100644 --- a/crates/malachite-app/METRICS.md +++ b/crates/malachite-app/METRICS.md @@ -71,9 +71,41 @@ This application exposes Prometheus metrics on the `/metrics` endpoint. The foll - `api`: The Engine API method name. - **Buckets:** Provides a histogram of API call times with exponential buckets from 0.001 to 2.0 seconds. +- **`arc_malachite_app_rpc_request_time`** (Histogram) + - **Description:** Time taken to serve RPC requests, in seconds. + - **Labels:** + - `endpoint`: The RPC endpoint path. + - **Buckets:** Provides a histogram of RPC request times with exponential buckets from 0.001 to 2.0 seconds. + + The `app_request_*` metric names below intentionally render with `app_app` in + Prometheus because the `arc_malachite_app` registry prefix is combined with + the registered `app_request_*` metric names. + +- **`arc_malachite_app_app_request_full_count`** (Counter) + - **Description:** Number of app requests rejected because the app request channel was full. + - **Labels:** + - `request`: The app request type. + +- **`arc_malachite_app_app_request_queue_time`** (Histogram) + - **Description:** Time app requests spend waiting in the app request channel, in seconds. + - **Labels:** + - `request`: The app request type. + - **Buckets:** Provides a histogram of app request queue times with exponential buckets from 0.001 to 2.0 seconds. + +- **`arc_malachite_app_app_request_process_time`** (Histogram) + - **Description:** Time taken to process app requests, in seconds. + - **Labels:** + - `request`: The app request type. + - **Buckets:** Provides a histogram of app request processing times with exponential buckets from 0.001 to 2.0 seconds. + - **`arc_malachite_app_height_restart_count`** (Counter) - **Description:** Number of times the consensus height has been restarted due to errors or recovery scenarios. +- **`arc_malachite_app_consensus_round_missed`** (Counter) + - **Description:** Number of consensus rounds that failed to decide before advancing to the next round. + - **Labels:** + - `proposer`: Address of the validator that was proposer for the missed round. + - **`arc_malachite_app_sync_fell_behind_count`** (Counter) - **Description:** Number of times the node fell behind and transitioned from InSync to CatchingUp. diff --git a/crates/malachite-app/src/app.rs b/crates/malachite-app/src/app.rs index a8193af..59802a0 100644 --- a/crates/malachite-app/src/app.rs +++ b/crates/malachite-app/src/app.rs @@ -16,19 +16,24 @@ use eyre::{eyre, Context as _}; use tokio::sync::mpsc::Receiver; +use tokio::sync::watch; +use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use malachitebft_app_channel::{AppMsg, Channels}; use malachitebft_core_types::utils::height::DisplayRange; -use malachitebft_core_types::Context; +use arc_consensus_types::proposer::{ProposerSelector, RoundRobin}; use arc_consensus_types::{ArcContext, StoredCommitCertificate}; use arc_eth_engine::engine::Engine; use crate::handlers::*; +use crate::metrics::AppMetrics; use crate::request::{AppRequest, CommitCertificateInfo}; -use crate::state::State; +use crate::state::{State, StatusSnapshot}; +use crate::stats::Stats; +use crate::store::Store; pub async fn run( mut state: State, @@ -41,11 +46,36 @@ pub async fn run( warn!("Consensus configured to halt at block height: {halt_height}"); } + let (status_tx, status_rx) = watch::channel(state.status_snapshot()); + + let ctx = AppRequestContext { + store: state.store().clone(), + engine: engine.clone(), + stats: state.stats().clone(), + metrics: state.metrics().clone(), + proposer_selector: state.ctx.proposer_selector, + status_rx, + }; + + let mut app_req_task: JoinHandle> = + tokio::spawn(process_app_requests(rx_app_req, ctx)); + let result = cancel_token - .run_until_cancelled_owned(go(&mut state, channels, &engine, rx_app_req)) + .run_until_cancelled_owned(go( + &mut state, + channels, + &engine, + status_tx, + &mut app_req_task, + )) .await; + // Always abort the app-request task, including on graceful cancellation + // where `go` is dropped at an await point and would otherwise detach it. + app_req_task.abort(); + let result = match result { + Some(Ok(never)) => match never {}, Some(Err(e)) => { error!("🔴 Error in application: {e:#}"); error!("🔴 Shutting down"); @@ -68,10 +98,42 @@ pub async fn run( /// Used to indicate that the function never returns normally. enum Never {} +/// Context shared with the spawned app-request processing task. +/// +/// All fields are cheap to clone (`Arc`-based or `Copy`). +struct AppRequestContext { + store: Store, + engine: Engine, + stats: Stats, + metrics: AppMetrics, + proposer_selector: RoundRobin, + status_rx: watch::Receiver, +} + +/// Runs in a dedicated tokio task, processing app requests concurrently +/// with the consensus event loop. +async fn process_app_requests( + mut rx: Receiver, + ctx: AppRequestContext, +) -> eyre::Result { + loop { + match rx.recv().await { + Some(req) => { + if let Err(e) = handle_app_request(req, &ctx).await { + error!("🔴 Error handling application request: {e:#}"); + } + } + None => { + return Err(eyre!("Application request channel closed unexpectedly")); + } + } + } +} + /// The main event loop of the application. /// -/// It listens for messages from consensus and application requests, -/// and dispatches them to the appropriate handlers. +/// It listens for messages from consensus and monitors the app-request task. +/// App requests are processed in a separate task so they don't block consensus. /// /// # Errors /// Returns an error if handling a message fails or one of the channels is closed unexpectedly. @@ -81,7 +143,8 @@ async fn go( state: &mut State, mut channels: Channels, engine: &Engine, - mut rx_app_req: Receiver, + status_tx: watch::Sender, + app_req_task: &mut JoinHandle>, ) -> eyre::Result { loop { tokio::select! { @@ -92,25 +155,33 @@ async fn go( // Abort on error to shut down the application. handle_consensus(msg, state, &mut channels, engine).await .wrap_err("Error handling consensus message")?; + + // Skip the publish when nothing changed: most consensus messages + // (sync queries, restream requests, vote-extension hooks) leave the + // snapshot untouched, so there is no point updating the watch. + status_tx.send_if_modified(|current| { + let new = state.status_snapshot(); + if *current != new { + *current = new; + true + } else { + false + } + }); }, None => { return Err(eyre!("Consensus channel closed unexpectedly")); } }, - req = rx_app_req.recv() => match req { - Some(req) => { - if let Err(e) = handle_app_request(req, state, engine).await { - error!("🔴 Error handling application request: {e:#}"); - - // We continue processing other requests even if one fails. - continue; - } - }, - None => { - return Err(eyre!("Application request channel closed unexpectedly")); + result = &mut *app_req_task => { + // The app-request task should run forever; if it exits, propagate the error. + match result { + Ok(Ok(never)) => match never {}, + Ok(Err(e)) => return Err(e.wrap_err("App request task failed")), + Err(e) => return Err(eyre!("App request task panicked: {e}")), } - } + }, } } } @@ -302,20 +373,35 @@ async fn handle_consensus( Ok(()) } -#[allow(clippy::unit_arg)] -async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> eyre::Result<()> { +async fn handle_app_request(req: AppRequest, ctx: &AppRequestContext) -> eyre::Result<()> { match req { - AppRequest::GetCertificate(height, reply) => { - let result = state - .store() - .get_certificate(height) - .await - .wrap_err_with(|| { - format!("GetCertificate: Failed to get certificate for height {height:?}") - })?; + AppRequest::GetCertificate { + height, + enqueued_at, + reply, + } => { + ctx.metrics.observe_app_request_queue_time( + "GetCertificate", + enqueued_at.elapsed().as_secs_f64(), + ); + let _guard = ctx + .metrics + .start_app_request_process_timer("GetCertificate"); + + let result = ctx.store.get_certificate(height).await.wrap_err_with(|| { + format!("GetCertificate: Failed to get certificate for height {height:?}") + })?; let info = match result { - Some(certificate) => get_certificate_info(&state.ctx, engine, certificate).await, + Some(certificate) => { + get_certificate_info( + ctx.proposer_selector, + &ctx.engine, + &ctx.metrics, + certificate, + ) + .await + } None => None, }; @@ -325,7 +411,7 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetMisbehaviorEvidence(height, reply) => { - let evidence = state.store().get_misbehavior_evidence(height).await.wrap_err_with(|| { + let evidence = ctx.store.get_misbehavior_evidence(height).await.wrap_err_with(|| { format!( "GetMisbehaviorEvidence: Failed to get misbehavior evidence for height {height:?}", ) @@ -336,8 +422,7 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetProposalMonitorData(height, reply) => { - let data = state - .store() + let data = ctx.store .get_proposal_monitor_data(height) .await .wrap_err_with(|| { @@ -352,7 +437,7 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetInvalidPayloads(height, reply) => { - let payloads = state.store().get_invalid_payloads(height).await.wrap_err_with(|| { + let payloads = ctx.store.get_invalid_payloads(height).await.wrap_err_with(|| { format!( "Failed to get invalid payloads for height {:?} in response to a GetInvalidPayloads request", height, ) @@ -363,8 +448,9 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetStatus(reply) => { - let status = state - .get_status() + let snapshot = ctx.status_rx.borrow().clone(); + let status = snapshot + .get_status(&ctx.store, &ctx.stats) .await .wrap_err("GetStatus: Failed to get the current status")?; @@ -374,13 +460,14 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetHealth(reply) => { - if let Err(e) = reply.send(state.get_health()) { + if let Err(e) = reply.send(()) { error!("GetHealth: Failed to reply: {e:?}"); } } AppRequest::GetSyncState(reply) => { - if let Err(e) = reply.send(state.sync_state) { + let sync_state = ctx.status_rx.borrow().sync_state; + if let Err(e) = reply.send(sync_state) { error!("GetSyncState: Failed to reply: {e:?}"); } } @@ -390,27 +477,39 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } async fn get_certificate_info( - ctx: &ArcContext, + proposer_selector: impl ProposerSelector, engine: &Engine, + metrics: &AppMetrics, stored: StoredCommitCertificate, ) -> Option { + if let Some(proposer) = stored.proposer { + return Some(CommitCertificateInfo { + certificate: stored.certificate, + certificate_type: stored.certificate_type, + proposer, + }); + } + // The validator set that signed the certificate is the one *before* executing that block, // since the block itself could contain validator set changes. let prev_height = stored.certificate.height.as_u64().saturating_sub(1); - let validator_set = engine - .eth - .get_active_validator_set(prev_height) - .await - .ok()?; + let validator_set = { + let _guard = + metrics.start_engine_api_timer("get_certificate_info.get_active_validator_set"); + engine + .eth + .get_active_validator_set(prev_height) + .await + .ok()? + }; - let proposer = stored.proposer.unwrap_or_else(|| { - ctx.select_proposer( + let proposer = proposer_selector + .select_proposer( &validator_set, stored.certificate.height, stored.certificate.round, ) - .address - }); + .address; Some(CommitCertificateInfo { certificate: stored.certificate, @@ -423,13 +522,17 @@ async fn get_certificate_info( mod tests { use super::*; + use arc_consensus_types::signing::PrivateKey; use arc_consensus_types::{ - Address, BlockHash, CommitCertificate, CommitCertificateType, Height, Round, ValueId, + Address, BlockHash, CommitCertificate, CommitCertificateType, Height, Round, Validator, + ValidatorSet, ValueId, }; use arc_eth_engine::engine::{MockEngineAPI, MockEthereumAPI}; use mockall::predicate::eq; + use rand::rngs::StdRng; + use rand::SeedableRng; - fn stored_cert(height: u64) -> StoredCommitCertificate { + fn stored_cert(height: u64, proposer: Option
) -> StoredCommitCertificate { StoredCommitCertificate { certificate: CommitCertificate::new( Height::new(height), @@ -438,53 +541,105 @@ mod tests { vec![], ), certificate_type: CommitCertificateType::Minimal, - // Set so get_certificate_info skips select_proposer (keeps the test focused - // on the validator set lookup). - proposer: Some(Address::new([0x42; 20])), + proposer, } } + fn validator_set() -> ValidatorSet { + let mut rng = StdRng::seed_from_u64(0x42); + let signing_key = PrivateKey::generate(&mut rng); + ValidatorSet::new(vec![Validator::new(signing_key.public_key(), 1)]) + } + + #[tokio::test] + async fn get_certificate_info_uses_stored_proposer_without_validator_set_lookup() { + let stored_proposer = Address::new([0x42; 20]); + let engine = Engine::new( + Box::new(MockEngineAPI::new()), + Box::new(MockEthereumAPI::new()), + ); + let metrics = AppMetrics::default(); + let ctx = ArcContext::default(); + + let info = get_certificate_info( + ctx.proposer_selector, + &engine, + &metrics, + stored_cert(42, Some(stored_proposer)), + ) + .await + .expect("should return Some"); + + assert_eq!(info.proposer, stored_proposer); + assert_eq!(info.certificate.height, Height::new(42)); + } + /// get_certificate_info must fetch the validator set at `certificate.height - 1` — the /// set that signed the certificate, i.e. the state *before* executing the certified block. #[tokio::test] - async fn get_certificate_info_queries_validator_set_at_prev_height() { + async fn get_certificate_info_queries_validator_set_at_prev_height_when_proposer_is_missing() { let cert_height = 42u64; + let fallback_validator_set = validator_set(); + let expected_proposer = fallback_validator_set + .get_by_index(0) + .expect("test validator set is non-empty") + .address; let mut mock_eth = MockEthereumAPI::new(); mock_eth .expect_get_active_validator_set() .with(eq(cert_height - 1)) .once() - .returning(|_| Ok(Default::default())); + .returning(move |_| Ok(fallback_validator_set.clone())); let engine = Engine::new(Box::new(MockEngineAPI::new()), Box::new(mock_eth)); + let metrics = AppMetrics::default(); let ctx = ArcContext::default(); - let info = get_certificate_info(&ctx, &engine, stored_cert(cert_height)) - .await - .expect("should return Some"); + let info = get_certificate_info( + ctx.proposer_selector, + &engine, + &metrics, + stored_cert(cert_height, None), + ) + .await + .expect("should return Some"); assert_eq!(info.certificate.height, Height::new(cert_height)); + assert_eq!(info.proposer, expected_proposer); } - /// At genesis (height 0), the saturating subtraction must keep the query at 0 rather - /// than underflowing. + /// At the first certificate height, the previous-height validator-set lookup + /// must query height 0. #[tokio::test] - async fn get_certificate_info_handles_genesis_height() { + async fn get_certificate_info_queries_validator_set_at_zero_for_first_certificate_height() { + let fallback_validator_set = validator_set(); + let expected_proposer = fallback_validator_set + .get_by_index(0) + .expect("test validator set is non-empty") + .address; + let mut mock_eth = MockEthereumAPI::new(); mock_eth .expect_get_active_validator_set() .with(eq(0u64)) .once() - .returning(|_| Ok(Default::default())); + .returning(move |_| Ok(fallback_validator_set.clone())); let engine = Engine::new(Box::new(MockEngineAPI::new()), Box::new(mock_eth)); + let metrics = AppMetrics::default(); let ctx = ArcContext::default(); - let info = get_certificate_info(&ctx, &engine, stored_cert(0)) - .await - .expect("should return Some"); + let info = get_certificate_info( + ctx.proposer_selector, + &engine, + &metrics, + stored_cert(1, None), + ) + .await + .expect("should return Some"); - assert_eq!(info.certificate.height, Height::new(0)); + assert_eq!(info.certificate.height, Height::new(1)); + assert_eq!(info.proposer, expected_proposer); } } diff --git a/crates/malachite-app/src/handlers/started_round.rs b/crates/malachite-app/src/handlers/started_round.rs index 76e7730..836097c 100644 --- a/crates/malachite-app/src/handlers/started_round.rs +++ b/crates/malachite-app/src/handlers/started_round.rs @@ -97,6 +97,15 @@ async fn on_started_round( assert!(round != Round::Nil, "Round cannot be Nil"); assert!(round >= state.current_round, "Round cannot go backwards"); + record_missed_rounds( + state.metrics(), + &state.ctx.proposer_selector, + state.validator_set(), + height, + state.current_round, + round, + ); + state.current_round = round; state.current_proposer = Some(proposer); @@ -113,6 +122,44 @@ async fn on_started_round( .await } +/// Increments the `consensus_round_missed` counter once for every round that was +/// started but skipped over between `prev_round` and `new_round`, attributing each +/// skipped round to the validator that round-robin would have made its proposer. +/// +/// `prev_round` is the height's previously started round (`state.current_round`), +/// with `Round::Nil` treated as round 0; the skipped rounds are the half-open +/// range `prev_round..new_round`, so the round that actually started is never +/// counted as missed. +/// +/// This is an at-least-once alerting signal, not an exact ledger. On a mid-height +/// restart `state.current_round` resets to `Round::Nil`, so a replayed +/// `StartedRound` for a round the node had already advanced past will re-count +/// rounds that were counted before the restart. The counter therefore never +/// under-counts missed rounds but may over-count across restarts — acceptable for +/// the alerting use case it serves. +fn record_missed_rounds( + metrics: &AppMetrics, + proposer_selector: &dyn ProposerSelector, + validator_set: &ValidatorSet, + height: Height, + prev_round: Round, + new_round: Round, +) { + let mut missed_round = prev_round.or(Round::Some(0)); + + while missed_round < new_round { + let missed_proposer = proposer_selector + .select_proposer(validator_set, height, missed_round) + .address; + + warn!(%missed_proposer, %height, %missed_round, "Consensus round missed"); + + metrics.inc_consensus_round_missed(missed_proposer); + + missed_round = missed_round.increment(); + } +} + #[allow(clippy::too_many_arguments)] async fn fetch_and_process_pending_proposals( height: Height, @@ -373,6 +420,110 @@ mod tests { (store, dir) } + #[test] + fn record_missed_rounds_counts_each_skipped_round() { + // Advancing from a fresh height (previous round Nil) straight to round 3 + // means rounds 0, 1 and 2 were started but never decided: three missed + // rounds, each attributed to that round's round-robin proposer. The round + // that actually started (3) is not itself a miss. + let keys: Vec = (0..4) + .map(|_| PrivateKey::generate(rand::rngs::OsRng)) + .collect(); + let validator_set = + ValidatorSet::new(keys.iter().map(|k| Validator::new(k.public_key(), 1))); + let selector = RoundRobin; + let height = Height::new(1); + let metrics = AppMetrics::default(); + + record_missed_rounds( + &metrics, + &selector, + &validator_set, + height, + Round::Nil, + Round::new(3), + ); + + // At height 1 the round-robin index is `round % 4`, so rounds 0..=3 map to + // four distinct proposers; assert exactly one increment per skipped round. + for missed in [Round::new(0), Round::new(1), Round::new(2)] { + let proposer = selector + .select_proposer(&validator_set, height, missed) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(proposer), + 1, + "round {missed} should be counted once against its proposer", + ); + } + + let started = selector + .select_proposer(&validator_set, height, Round::new(3)) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(started), + 0, + "the round that actually started is not a missed round", + ); + } + + #[test] + fn record_missed_rounds_starting_round_zero_is_noop() { + // Starting round 0 of a fresh height (previous round Nil) skips nothing. + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator_set = ValidatorSet::new(vec![Validator::new(signing_key.public_key(), 1)]); + let selector = RoundRobin; + let height = Height::new(1); + let metrics = AppMetrics::default(); + + record_missed_rounds( + &metrics, + &selector, + &validator_set, + height, + Round::Nil, + Round::new(0), + ); + + let proposer = selector + .select_proposer(&validator_set, height, Round::new(0)) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(proposer), + 0, + "no round is skipped when starting at round 0", + ); + } + + #[test] + fn record_missed_rounds_same_round_is_noop() { + // prev_round == new_round exercises the `missed_round < new_round` loop + // boundary: re-entering the current round counts nothing. + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator_set = ValidatorSet::new(vec![Validator::new(signing_key.public_key(), 1)]); + let selector = RoundRobin; + let height = Height::new(1); + let metrics = AppMetrics::default(); + + record_missed_rounds( + &metrics, + &selector, + &validator_set, + height, + Round::new(2), + Round::new(2), + ); + + let proposer = selector + .select_proposer(&validator_set, height, Round::new(2)) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(proposer), + 0, + "re-entering the same round is not a miss", + ); + } + #[tokio::test] async fn validate_undecided_blocks_all_valid() { let height = Height::new(1); diff --git a/crates/malachite-app/src/metrics/app.rs b/crates/malachite-app/src/metrics/app.rs index c8eca57..7674c8e 100644 --- a/crates/malachite-app/src/metrics/app.rs +++ b/crates/malachite-app/src/metrics/app.rs @@ -92,6 +92,18 @@ pub struct Inner { /// Time taken for each Engine API call engine_api_time: Family, + /// Time taken to serve RPC requests + rpc_request_time: Family, + + /// Number of app requests rejected because the app request channel was full + app_request_full_count: Family, + + /// Time app requests spend waiting in the app request channel + app_request_queue_time: Family, + + /// Time taken to process app requests + app_request_process_time: Family, + /// The number of times the consensus height has been restarted height_restart_count: Counter, @@ -111,6 +123,9 @@ pub struct Inner { /// Number of blocks replayed from CL to EL during startup handshake handshake_replay_blocks: Gauge, + /// Number of consensus rounds that failed to decide before advancing to the next round + consensus_round_missed: Family, + /// Internal state recording the previous validator set. /// Useful field to manage validators' metrics. /// This field is only accessible internally, and is not a metrics itself. @@ -139,12 +154,23 @@ impl Inner { engine_api_time: Family::new_with_constructor(|| { Histogram::new(exponential_buckets_range(0.001, 2.0, 10)) }), + rpc_request_time: Family::new_with_constructor(|| { + Histogram::new(exponential_buckets_range(0.001, 2.0, 16)) + }), + app_request_full_count: Family::default(), + app_request_queue_time: Family::new_with_constructor(|| { + Histogram::new(exponential_buckets_range(0.001, 2.0, 16)) + }), + app_request_process_time: Family::new_with_constructor(|| { + Histogram::new(exponential_buckets_range(0.001, 2.0, 16)) + }), height_restart_count: Counter::default(), sync_fell_behind_count: Counter::default(), invalid_payloads_count: Family::default(), pending_proposal_parts_count: Gauge::default(), consensus_params: Family::default(), handshake_replay_blocks: Gauge::default(), + consensus_round_missed: Family::default(), } } } @@ -250,6 +276,30 @@ impl AppMetrics { metrics.engine_api_time.clone(), ); + registry.register( + "rpc_request_time", + "Time taken to serve RPC requests, in seconds", + metrics.rpc_request_time.clone(), + ); + + registry.register( + "app_request_full_count", + "Number of app requests rejected because the app request channel was full", + metrics.app_request_full_count.clone(), + ); + + registry.register( + "app_request_queue_time", + "Time app requests spend waiting in the app request channel, in seconds", + metrics.app_request_queue_time.clone(), + ); + + registry.register( + "app_request_process_time", + "Time taken to process app requests, in seconds", + metrics.app_request_process_time.clone(), + ); + registry.register( "height_restart_count", "The number of times the consensus height has been restarted", @@ -280,6 +330,12 @@ impl AppMetrics { metrics.handshake_replay_blocks.clone(), ); + registry.register( + "consensus_round_missed", + "Number of consensus rounds that failed to decide before advancing to the next round", + metrics.consensus_round_missed.clone(), + ); + // Register version info as a separate Info metric let version_info = Info::new(VersionInfoLabel { version: arc_version::SHORT_VERSION, @@ -419,6 +475,46 @@ impl AppMetrics { }) } + /// Start a timer for an RPC request. + /// + /// The returned guard will record the time taken for the request when dropped. + #[must_use] + pub fn start_rpc_request_timer(&self, endpoint: &'static str) -> MetricsGuard { + MetricsGuard::new(self.clone(), endpoint, |metrics, endpoint, elapsed| { + metrics + .rpc_request_time + .get_or_create(&RpcEndpointLabel::new(endpoint)) + .observe(elapsed.as_secs_f64()); + }) + } + + /// Increment the number of app requests rejected by a full app request channel. + pub fn inc_app_request_full_count(&self, request: &'static str) { + self.app_request_full_count + .get_or_create(&AppRequestLabel::new(request)) + .inc(); + } + + /// Observe the time an app request spent waiting in the app request channel. + pub fn observe_app_request_queue_time(&self, request: &'static str, seconds: f64) { + self.app_request_queue_time + .get_or_create(&AppRequestLabel::new(request)) + .observe(seconds); + } + + /// Start a timer for app request processing. + /// + /// The returned guard will record processing time when dropped. + #[must_use] + pub fn start_app_request_process_timer(&self, request: &'static str) -> MetricsGuard { + MetricsGuard::new(self.clone(), request, |metrics, request, elapsed| { + metrics + .app_request_process_time + .get_or_create(&AppRequestLabel::new(request)) + .observe(elapsed.as_secs_f64()); + }) + } + /// Increment the number of times the consensus height has been restarted pub fn inc_height_restart_count(&self) { self.height_restart_count.inc(); @@ -468,6 +564,21 @@ impl AppMetrics { pub fn get_handshake_replay_blocks(&self) -> u64 { self.handshake_replay_blocks.get() } + + /// Record that a consensus round failed to decide before advancing. + pub fn inc_consensus_round_missed(&self, proposer: Address) { + self.consensus_round_missed + .get_or_create(&RoundMissedLabel::new(proposer)) + .inc(); + } + + /// Total number of missed consensus rounds since start. + #[cfg(test)] + pub fn get_consensus_round_missed_count(&self, proposer: Address) -> u64 { + self.consensus_round_missed + .get_or_create(&RoundMissedLabel::new(proposer)) + .get() + } } impl Default for AppMetrics { @@ -505,6 +616,19 @@ impl AddressLabel { } } +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct RoundMissedLabel { + proposer: AsLabelValue
, +} + +impl RoundMissedLabel { + fn new(proposer: Address) -> Self { + Self { + proposer: AsLabelValue(proposer), + } + } +} + #[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] struct ProcessMsgLabel { msg: &'static str, @@ -527,6 +651,28 @@ impl EngineApiLabel { } } +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct RpcEndpointLabel { + endpoint: &'static str, +} + +impl RpcEndpointLabel { + fn new(endpoint: &'static str) -> Self { + Self { endpoint } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct AppRequestLabel { + request: &'static str, +} + +impl AppRequestLabel { + fn new(request: &'static str) -> Self { + Self { request } + } +} + /// Source of an invalid-payload record, used to label the counter. #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum InvalidPayloadSource { @@ -641,4 +787,16 @@ mod tests { "Metrics should not contain 0x prefix: {buf}" ); } + + #[test] + fn test_consensus_round_missed_counter() { + let metrics = AppMetrics::new(); + let proposer = Address::new([0xAA; 20]); + + metrics.inc_consensus_round_missed(proposer); + metrics.inc_consensus_round_missed(proposer); + metrics.inc_consensus_round_missed(proposer); + + assert_eq!(metrics.get_consensus_round_missed_count(proposer), 3); + } } diff --git a/crates/malachite-app/src/node.rs b/crates/malachite-app/src/node.rs index ae51a75..e8a6576 100644 --- a/crates/malachite-app/src/node.rs +++ b/crates/malachite-app/src/node.rs @@ -566,6 +566,7 @@ impl App { fn start_rpc_server( &self, channels: &Channels, + metrics: AppMetrics, ) -> ( mpsc::Sender, mpsc::Receiver, @@ -578,11 +579,13 @@ impl App { let listen_addr = self.config.rpc.listen_addr; let request_handle = channels.requests.clone(); let net_request_handle = channels.net_requests.clone(); - crate::rpc::serve( + let metrics = metrics.clone(); + crate::rpc::serve_with_metrics( listen_addr, request_handle, tx_rpc_req.clone(), net_request_handle, + metrics, ) }); Some(join_handle) @@ -710,7 +713,8 @@ impl App { let (channels, engine_handle) = self.start_consensus_engine(ctx, identity).await?; // Start the application RPC server - let (tx_app_req, rx_app_req, rpc_handle) = self.start_rpc_server(&channels); + let (tx_app_req, rx_app_req, rpc_handle) = + self.start_rpc_server(&channels, state.metrics().clone()); let tx_event = channels.events.clone(); let cancel_token = CancellationToken::new(); diff --git a/crates/malachite-app/src/request.rs b/crates/malachite-app/src/request.rs index 304eab8..2791e26 100644 --- a/crates/malachite-app/src/request.rs +++ b/crates/malachite-app/src/request.rs @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::time::SystemTime; +use std::time::{Instant, SystemTime}; use tokio::sync::{mpsc, oneshot}; use tracing::error; @@ -80,12 +80,14 @@ pub struct CommitCertificateInfo { #[allow(clippy::enum_variant_names)] pub enum AppRequest { /// Retrieves a commit certificate at the given height - GetCertificate( + GetCertificate { /// The height to get the certificate for. If None, get the latest certificate. - Option, + height: Option, + /// The time the request was enqueued. + enqueued_at: Instant, /// The channel to send the certificate back on. - oneshot::Sender>, - ), + reply: oneshot::Sender>, + }, /// Retrieves misbehavior evidence at the given height GetMisbehaviorEvidence( /// The height to get the evidence for. If None, use the latest height. @@ -127,7 +129,11 @@ impl AppRequest { ) -> Result, AppRequestError> { let (tx, rx) = oneshot::channel(); tx_app_req - .try_send(Self::GetCertificate(height, tx)) + .try_send(Self::GetCertificate { + height, + enqueued_at: Instant::now(), + reply: tx, + }) .inspect_err(|e| error!("Failed to send GetCertificate request to consensus: {e}"))?; let cert = rx.await.inspect_err(|e| { diff --git a/crates/malachite-app/src/rpc/handlers.rs b/crates/malachite-app/src/rpc/handlers.rs index 81b45a5..9b1b0b0 100644 --- a/crates/malachite-app/src/rpc/handlers.rs +++ b/crates/malachite-app/src/rpc/handlers.rs @@ -25,8 +25,9 @@ use serde_json::json; use malachitebft_app_channel::ConsensusRequest; use malachitebft_app_channel::NetworkRequest; -use crate::request::AppRequest; +use crate::metrics::AppMetrics; use crate::request::TxAppReq; +use crate::request::{AppRequest, AppRequestError}; use crate::rpc::types::persistent_peer_error_to_response; use crate::rpc::types::request_error_to_response; use crate::rpc::types::RpcVersion; @@ -72,14 +73,20 @@ pub(crate) async fn get_network_state( } pub(crate) async fn get_commit( + metrics: State, tx_app_req: State, query: Query, Extension(version): Extension, ) -> impl IntoResponse { + let _guard = metrics.start_rpc_request_timer("/commit"); tracing::debug!(?version, "get_commit called"); - AppRequest::get_certificate(query.height, &tx_app_req) - .await + let result = AppRequest::get_certificate(query.height, &tx_app_req).await; + if matches!(result, Err(AppRequestError::Full)) { + metrics.inc_app_request_full_count("GetCertificate"); + } + + result .map_err(request_error_to_response)? .map(|cert| Json(RpcCommitCertificate::from(cert))) .ok_or_else(|| { diff --git a/crates/malachite-app/src/rpc/mod.rs b/crates/malachite-app/src/rpc/mod.rs index 54d8e4c..69c9498 100644 --- a/crates/malachite-app/src/rpc/mod.rs +++ b/crates/malachite-app/src/rpc/mod.rs @@ -27,3 +27,4 @@ mod version; pub use routes::build_router; pub use routes::serve; +pub(crate) use routes::serve_with_metrics; diff --git a/crates/malachite-app/src/rpc/routes.rs b/crates/malachite-app/src/rpc/routes.rs index 16a4f8c..2bb0ae7 100644 --- a/crates/malachite-app/src/rpc/routes.rs +++ b/crates/malachite-app/src/rpc/routes.rs @@ -28,6 +28,7 @@ use tracing::{error, info}; use super::middleware::extract_version; use super::types::{EndpointInfo, RpcState, TxConsensusReq, TxNetworkReq}; use super::version::ApiVersion; +use crate::metrics::AppMetrics; use crate::request::TxAppReq; // List of RPC routes. @@ -131,7 +132,32 @@ pub async fn serve( tx_app_req: TxAppReq, tx_network_req: TxNetworkReq, ) { - if let Err(e) = inner(listen_addr, tx_consensus_req, tx_app_req, tx_network_req).await { + serve_with_metrics( + listen_addr, + tx_consensus_req, + tx_app_req, + tx_network_req, + AppMetrics::default(), + ) + .await +} + +pub(crate) async fn serve_with_metrics( + listen_addr: impl ToSocketAddrs, + tx_consensus_req: TxConsensusReq, + tx_app_req: TxAppReq, + tx_network_req: TxNetworkReq, + metrics: AppMetrics, +) { + if let Err(e) = inner( + listen_addr, + tx_consensus_req, + tx_app_req, + tx_network_req, + metrics, + ) + .await + { error!("RPC server failed: {e}"); } } @@ -144,11 +170,26 @@ pub fn build_router( tx_consensus_req: TxConsensusReq, tx_app_req: TxAppReq, tx_network_req: TxNetworkReq, +) -> Router { + build_router_with_metrics( + tx_consensus_req, + tx_app_req, + tx_network_req, + AppMetrics::default(), + ) +} + +pub(crate) fn build_router_with_metrics( + tx_consensus_req: TxConsensusReq, + tx_app_req: TxAppReq, + tx_network_req: TxNetworkReq, + metrics: AppMetrics, ) -> Router { let rpc_state = RpcState { tx_consensus_req, tx_app_req, tx_network_req, + metrics, }; let routes = build_routes(); @@ -179,8 +220,9 @@ async fn inner( tx_consensus_req: TxConsensusReq, tx_app_req: TxAppReq, tx_network_req: TxNetworkReq, + metrics: AppMetrics, ) -> Result<()> { - let app = build_router(tx_consensus_req, tx_app_req, tx_network_req); + let app = build_router_with_metrics(tx_consensus_req, tx_app_req, tx_network_req, metrics); let listener = TcpListener::bind(listen_addr).await?; let address = listener.local_addr()?; @@ -347,7 +389,12 @@ mod tests { let _ = reply.send(state); } MockConfig::AppGetCertificate(ret) => { - let Some(AppRequest::GetCertificate(None, reply_port)) = msg else { + let Some(AppRequest::GetCertificate { + height: None, + reply: reply_port, + .. + }) = msg + else { panic!("Unexpected msg"); }; let _ = reply_port.send(match ret { diff --git a/crates/malachite-app/src/rpc/types.rs b/crates/malachite-app/src/rpc/types.rs index be05ab2..2703c49 100644 --- a/crates/malachite-app/src/rpc/types.rs +++ b/crates/malachite-app/src/rpc/types.rs @@ -45,6 +45,7 @@ use malachitebft_core_state_machine::state::{RoundValue, State as MState}; use malachitebft_core_types::{NilOrVal, VoteType}; use malachitebft_network::PersistentPeerError; +use crate::metrics::AppMetrics; use crate::request::{AppRequestError, CommitCertificateInfo, Status, TxAppReq}; use crate::utils::sync_state::SyncState; use alloy_rpc_types_engine::ExecutionPayloadV3; @@ -60,6 +61,7 @@ pub(crate) struct RpcState { pub tx_consensus_req: TxConsensusReq, pub tx_app_req: TxAppReq, pub tx_network_req: TxNetworkReq, + pub metrics: AppMetrics, } pub(crate) struct RouteDef { @@ -805,6 +807,58 @@ impl From for RpcInvalidPayload { } } +#[cfg(test)] +mod tests_rendering { + use super::*; + use arc_consensus_types::signing::PrivateKey; + use malachitebft_core_types::Round; + use rand::rngs::OsRng; + use std::time::SystemTime; + + #[test] + fn rpc_validator_renders_public_key_as_0x_lowercase_hex() { + let private_key = PrivateKey::generate(OsRng); + let public_key = private_key.public_key(); + let key_bytes = public_key.as_bytes().to_vec(); + let validator = Validator::new(public_key, 100); + + let rpc = RpcValidator::from(&validator); + + // Renders as `0x` + lowercase hex (hex::encode is lowercase by construction). + let expected = format!("0x{}", hex::encode(&key_bytes)); + assert_eq!(rpc.public_key_hex, expected); + } + + #[test] + fn rpc_app_status_renders_public_key_as_0x_lowercase_hex() { + let private_key = PrivateKey::generate(OsRng); + let public_key = private_key.public_key(); + let key_bytes = public_key.as_bytes().to_vec(); + + let status = Status { + height: Height::new(1), + round: Round::new(0), + address: Address::from_public_key(&public_key), + public_key, + proposer: None, + height_start_time: SystemTime::UNIX_EPOCH, + prev_payload_hash: None, + db_latest_height: Height::default(), + db_earliest_height: Height::default(), + undecided_blocks_count: 0, + pending_proposal_parts: Vec::new(), + validator_set: ValidatorSet::default(), + sync_state: SyncState::InSync, + }; + + let rpc = RpcAppStatus::from(status); + + // Renders as `0x` + lowercase hex (hex::encode is lowercase by construction). + let expected = format!("0x{}", hex::encode(&key_bytes)); + assert_eq!(rpc.public_key, expected); + } +} + #[cfg(test)] mod tests_network { use super::*; diff --git a/crates/malachite-app/src/state.rs b/crates/malachite-app/src/state.rs index ea3d239..f996e9a 100644 --- a/crates/malachite-app/src/state.rs +++ b/crates/malachite-app/src/state.rs @@ -28,26 +28,98 @@ use malachitebft_app_channel::app::types::core::Round; use crate::streaming; use arc_consensus_types::{ - Address, AlloyAddress, ArcContext, BlockHash, ChainId, Config, ConsensusParams, ConsensusSpec, - Height, NetworkId, ValidatorSet, + signing::PublicKey, Address, AlloyAddress, ArcContext, BlockHash, ChainId, Config, + ConsensusParams, ConsensusSpec, Height, NetworkId, ValidatorSet, }; use arc_eth_engine::json_structures::ExecutionBlock; use arc_eth_engine::persistence_meter::{NoopPersistenceMeter, PersistenceMeter}; use arc_signer::ArcSigningProvider; use malachitebft_core_types::HeightParams; -use crate::block::ConsensusBlock; use crate::env_config::EnvConfig; use crate::metrics::app::AppMetrics; use crate::node::ConsensusIdentity; use crate::request::Status; use crate::stats::Stats; -use crate::store::repositories::UndecidedBlocksRepository; use crate::store::Store; use crate::streaming::PartStreamsMap; use crate::utils::sync_state::SyncState; use arc_consensus_types::proposal_monitor::ProposalMonitor; +/// A snapshot of the volatile consensus state fields needed to serve status queries. +/// +/// This is published via a `tokio::sync::watch` channel after each consensus message, +/// so that the app-request task can read it without blocking the consensus loop. +/// +/// The snapshot is eventually consistent with `State`: it is republished after the +/// consensus loop finishes handling a message, so a reader may observe a snapshot +/// that is up to one consensus-message stale. Fields derived from it (e.g. the +/// `(height, round)` used to query `get_undecided_blocks`) inherit that staleness. +/// This is acceptable for the `/status` RPC — strict freshness would require +/// re-coupling the request handler to the consensus loop. +#[derive(Clone, Debug, PartialEq)] +pub struct StatusSnapshot { + pub height: Height, + pub round: Round, + pub proposer: Option
, + pub address: Address, + pub public_key: PublicKey, + pub previous_block: Option, + pub validator_set: ValidatorSet, + pub sync_state: SyncState, +} + +impl StatusSnapshot { + /// Build the full `Status` response from this snapshot plus live DB/stats queries. + pub async fn get_status(&self, store: &Store, stats: &Stats) -> eyre::Result { + let undecided_blocks_count = store + .get_undecided_blocks(self.height, self.round) + .await + .wrap_err_with(|| { + format!( + "Failed to get undecided blocks for height {} and round {} from the state", + self.height, self.round, + ) + })? + .len(); + + let db_latest_height = store + .max_height() + .await + .wrap_err("Failed to get the latest height from the state")? + .unwrap_or_default(); + + let db_earliest_height = store + .min_height() + .await + .wrap_err("Failed to get earliest height from the state")? + .unwrap_or_default(); + + let pending_proposal_parts = store + .get_pending_proposal_parts_counts() + .await + .wrap_err("Failed to get pending proposal parts counts from the state")?; + + Ok(Status { + height: self.height, + round: self.round, + address: self.address, + public_key: self.public_key, + proposer: self.proposer, + // elapsed() is always <= time since epoch, so this won't underflow + #[allow(clippy::arithmetic_side_effects)] + height_start_time: SystemTime::now() - stats.height_started().elapsed(), + prev_payload_hash: self.previous_block.map(|b| b.block_hash), + db_latest_height, + db_earliest_height, + undecided_blocks_count, + pending_proposal_parts, + validator_set: self.validator_set.clone(), + sync_state: self.sync_state, + }) + } +} + /// Information needed to start the next height after a decision is reached. #[derive(Debug)] pub struct NextHeightInfo { @@ -386,69 +458,18 @@ impl State { limit } - /// Return important current information. - pub async fn get_status(&self) -> eyre::Result { - let undecided_blocks_count = self - .get_undecided_blocks(self.current_height, self.current_round) - .await - .wrap_err_with(|| { - format!( - "Failed to get undecided blocks for height {} and round {} from the state", - self.current_height, self.current_round, - ) - })? - .len(); - - let pending_proposal_parts = self - .store - .get_pending_proposal_parts_counts() - .await - .wrap_err("Failed to get pending proposal parts counts from the state")?; - - Ok(Status { + /// Create a snapshot of the volatile consensus state fields. + pub fn status_snapshot(&self) -> StatusSnapshot { + StatusSnapshot { height: self.current_height, round: self.current_round, + proposer: self.current_proposer, address: self.address(), public_key: *self.identity.public_key(), - proposer: self.current_proposer, - // elapsed() is always <= time since epoch, so this won't underflow - #[allow(clippy::arithmetic_side_effects)] - height_start_time: SystemTime::now() - self.stats.height_started().elapsed(), - prev_payload_hash: self.previous_block.map(|b| b.block_hash), - db_latest_height: self - .store() - .max_height() - .await - .wrap_err("Failed to get the latest height from the state")? - .unwrap_or_default(), - db_earliest_height: self - .store() - .min_height() - .await - .wrap_err("Failed to get earliest height from the state")? - .unwrap_or_default(), - undecided_blocks_count, - pending_proposal_parts, - validator_set: self.validator_set().to_owned(), + previous_block: self.previous_block, + validator_set: self.validator_set().clone(), sync_state: self.sync_state, - }) - } - - /// Return unit type. Used to check the app is active. - pub fn get_health(&self) {} - - /// Retrieves all undecided blocks at the given height and round. - pub async fn get_undecided_blocks( - &self, - height: Height, - round: Round, - ) -> eyre::Result> { - self.store - .get_by_round(height, round) - .await - .wrap_err_with(|| { - format!("Failed to get undecided blocks for height {height} and round {round} from the database") - }) + } } /// Move to the next height, updating the previous block, validator set, and consensus params. diff --git a/crates/malachite-app/tests/rpc_integration.rs b/crates/malachite-app/tests/rpc_integration.rs index 498ad5e..95afb94 100644 --- a/crates/malachite-app/tests/rpc_integration.rs +++ b/crates/malachite-app/tests/rpc_integration.rs @@ -160,7 +160,7 @@ async fn test_commit_endpoint_with_height() { // Respond to certificate request server.expect_app_request(|req| match req { - AppRequest::GetCertificate(height, reply) => { + AppRequest::GetCertificate { height, reply, .. } => { assert_eq!(height, Some(Height::new(42))); reply.send(None).ok(); } @@ -184,7 +184,7 @@ async fn test_commit_endpoint_without_height() { // Respond to certificate request server.expect_app_request(|req| match req { - AppRequest::GetCertificate(height, reply) => { + AppRequest::GetCertificate { height, reply, .. } => { assert_eq!(height, None); reply.send(None).ok(); } diff --git a/crates/precompiles/src/call_from.rs b/crates/precompiles/src/call_from.rs index 328c876..84968dd 100644 --- a/crates/precompiles/src/call_from.rs +++ b/crates/precompiles/src/call_from.rs @@ -190,6 +190,8 @@ impl SubcallPrecompile for CallFromPrecompile { #[cfg(test)] mod tests { use super::*; + use alloy_primitives::Bytes; + use revm::interpreter::{CallOutcome, Gas, InstructionResult, InterpreterResult}; /// Guard against silent upstream changes to the EVM COPY gas cost. /// An unexpected change would alter `abi_decode_gas` and `abi_encode_gas` results, @@ -282,4 +284,155 @@ mod tests { "is_static mismatch" ); } + + fn make_call_frame_result(result: InstructionResult, output: Bytes) -> FrameResult { + FrameResult::Call(CallOutcome { + result: InterpreterResult::new(result, output, Gas::new(0)), + memory_offset: 0..0, + was_precompile_called: false, + precompile_call_logs: Default::default(), + }) + } + + fn empty_continuation() -> SubcallContinuationData { + SubcallContinuationData { + state: Box::new(()), + } + } + + #[test] + fn complete_subcall_child_success_encodes_success_true() { + let precompile = CallFromPrecompile; + let output: Bytes = vec![0xde, 0xad, 0xbe, 0xef].into(); + let child = make_call_frame_result(InstructionResult::Return, output.clone()); + + let completion = precompile + .complete_subcall(empty_continuation(), &child) + .expect("Call frame result should produce Ok"); + + let decoded = ICallFrom::callFromCall::abi_decode_returns(&completion.output) + .expect("output should decode as callFrom return"); + assert!(decoded.success, "child Return ⇒ success=true"); + assert_eq!(decoded.returnData.as_ref(), output.as_ref()); + assert_eq!(completion.gas_overhead, abi_encode_gas(output.len())); + assert!( + completion.success, + "precompile completion is always success=true; the caller inspects the encoded bool" + ); + } + + #[test] + fn complete_subcall_child_revert_encodes_success_false() { + let precompile = CallFromPrecompile; + let revert_output: Bytes = vec![0x08, 0xc3, 0x79, 0xa0].into(); + let child = make_call_frame_result(InstructionResult::Revert, revert_output.clone()); + + let completion = precompile + .complete_subcall(empty_continuation(), &child) + .expect("Call frame result with Revert should still produce Ok"); + + let decoded = ICallFrom::callFromCall::abi_decode_returns(&completion.output) + .expect("output should decode as callFrom return"); + assert!(!decoded.success, "child Revert ⇒ success=false"); + assert_eq!(decoded.returnData.as_ref(), revert_output.as_ref()); + assert_eq!(completion.gas_overhead, abi_encode_gas(revert_output.len())); + } + + #[test] + fn complete_subcall_non_call_frame_result_errors() { + use revm_interpreter::CreateOutcome; + + let precompile = CallFromPrecompile; + let create_result = FrameResult::Create(CreateOutcome { + result: InterpreterResult::new(InstructionResult::Return, Bytes::new(), Gas::new(0)), + address: None, + }); + + let result = precompile.complete_subcall(empty_continuation(), &create_result); + assert!( + matches!(result, Err(SubcallError::UnexpectedFrameResult)), + "non-Call FrameResult must yield UnexpectedFrameResult" + ); + } + + fn call_from_inputs(data: Vec, gas_limit: u64) -> CallInputs { + use alloy_primitives::address; + use alloy_sol_types::SolCall; + use revm::interpreter::interpreter_action::{CallInput, CallScheme, CallValue}; + + let calldata = ICallFrom::callFromCall { + sender: address!("e000000000000000000000000000000000000001"), + target: address!("c000000000000000000000000000000000000002"), + data: data.into(), + } + .abi_encode(); + + CallInputs { + scheme: CallScheme::Call, + target_address: CALL_FROM_ADDRESS, + bytecode_address: CALL_FROM_ADDRESS, + known_bytecode: None, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.into()), + gas_limit, + is_static: false, + caller: address!("c000000000000000000000000000000000000001"), + return_memory_offset: 0..0, + } + } + + #[test] + fn abi_decode_gas_base_plus_per_word() { + // 0 bytes → base only (div_ceil(0, 32) = 0) + assert_eq!(abi_decode_gas(0), ABI_DECODE_BASE_GAS); + // 1 byte → 1 word + assert_eq!(abi_decode_gas(1), ABI_DECODE_BASE_GAS + gas::COPY); + // 32 bytes → 1 word + assert_eq!(abi_decode_gas(32), ABI_DECODE_BASE_GAS + gas::COPY); + // 33 bytes → 2 words + assert_eq!(abi_decode_gas(33), ABI_DECODE_BASE_GAS + 2 * gas::COPY); + // 64 bytes → 2 words + assert_eq!(abi_decode_gas(64), ABI_DECODE_BASE_GAS + 2 * gas::COPY); + } + + #[test] + fn init_subcall_below_abi_decode_overhead_returns_insufficient_gas() { + let precompile = CallFromPrecompile; + // Empty data: overhead = abi_decode_gas(0). One gas short must revert. + let inputs = call_from_inputs(vec![], abi_decode_gas(0) - 1); + assert!( + matches!( + precompile.init_subcall(&inputs), + Err(SubcallError::InsufficientGas(_)) + ), + "gas_limit one below the ABI decode overhead must return InsufficientGas" + ); + } + + #[test] + fn init_subcall_at_abi_decode_overhead_succeeds() { + let precompile = CallFromPrecompile; + // Exactly the overhead leaves zero gas for the child but still completes init. + let inputs = call_from_inputs(vec![], abi_decode_gas(0)); + let result = precompile + .init_subcall(&inputs) + .expect("gas_limit equal to overhead should succeed"); + assert_eq!(result.gas_overhead, abi_decode_gas(0)); + } + + #[test] + fn init_subcall_below_per_word_overhead_returns_insufficient_gas() { + let precompile = CallFromPrecompile; + // 33 bytes → 2 words → overhead = base + 2*COPY. One gas short must revert. + let data = vec![0u8; 33]; + let overhead = abi_decode_gas(data.len()); + let inputs = call_from_inputs(data, overhead - 1); + assert!( + matches!( + precompile.init_subcall(&inputs), + Err(SubcallError::InsufficientGas(_)) + ), + "nonempty data: gas below base+per-word overhead must return InsufficientGas" + ); + } } diff --git a/crates/precompiles/src/helpers.rs b/crates/precompiles/src/helpers.rs index 13e4493..6cad823 100644 --- a/crates/precompiles/src/helpers.rs +++ b/crates/precompiles/src/helpers.rs @@ -82,6 +82,8 @@ pub fn revert_message_to_bytes(msg: &str) -> Bytes { /// Zero6+: also applied to authorization and validation reverts (unauthorized /// caller, blocklist, zero address, zero amount, overflow) via /// [`new_reverted_with_early_penalty`]. +/// +/// Zero8+: also applied to delegatecall rejections via [`check_delegatecall`]. pub(crate) const PRECOMPILE_EARLY_REVERT_GAS_PENALTY: u64 = 200; /// Enum to represent either a reverted precompile output or an error @@ -552,14 +554,20 @@ pub(crate) fn check_delegatecall( precompile_address: Address, precompile_input: &PrecompileInput, gas_counter: &Gas, + hardfork_flags: ArcHardforkFlags, ) -> Result<(), PrecompileErrorOrRevert> { if precompile_input.target_address != precompile_address || precompile_input.bytecode_address != precompile_address { - return Err(PrecompileErrorOrRevert::new_reverted( - *gas_counter, - ERR_DELEGATE_CALL_NOT_ALLOWED, - )); + return Err(if hardfork_flags.is_active(ArcHardfork::Zero8) { + PrecompileErrorOrRevert::new_reverted_with_penalty( + *gas_counter, + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + ERR_DELEGATE_CALL_NOT_ALLOWED, + ) + } else { + PrecompileErrorOrRevert::new_reverted(*gas_counter, ERR_DELEGATE_CALL_NOT_ALLOWED) + }); } Ok(()) } @@ -1071,4 +1079,43 @@ mod tests { assert!(matches!(result, Err(PrecompileError::OutOfGas))); } + + #[test] + fn new_reverted_with_early_penalty_is_zero6_gated() { + let pre_zero6 = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); + let zero6 = ArcHardforkFlags::with(&[ArcHardfork::Zero6]); + + let pre = new_reverted_with_early_penalty(Gas::new(10_000), "err", pre_zero6); + let pre_output: Result = pre.into(); + let pre_output = pre_output.expect("pre-Zero6 returns Revert"); + assert_eq!(pre_output.gas_used, 0); + assert!(pre_output.reverted); + + let post = new_reverted_with_early_penalty(Gas::new(10_000), "err", zero6); + let post_output: Result = post.into(); + let post_output = post_output.expect("Zero6 returns Revert"); + assert_eq!(post_output.gas_used, PRECOMPILE_EARLY_REVERT_GAS_PENALTY); + assert!(post_output.reverted); + } + + #[test] + fn new_reverted_with_early_penalty_oog_under_zero6() { + let zero6 = ArcHardforkFlags::with(&[ArcHardfork::Zero6]); + let result = new_reverted_with_early_penalty( + Gas::new(PRECOMPILE_EARLY_REVERT_GAS_PENALTY - 1), + "err", + zero6, + ); + assert!(matches!( + result, + PrecompileErrorOrRevert::Error(PrecompileError::OutOfGas) + )); + } + + #[test] + fn precompile_early_revert_gas_penalty_is_200() { + // Zero6 consensus rule: early-path reverts on stateful precompiles charge a uniform + // 200-gas penalty. Pin the literal so a silent change is caught. + assert_eq!(PRECOMPILE_EARLY_REVERT_GAS_PENALTY, 200); + } } diff --git a/crates/precompiles/src/native_coin_authority.rs b/crates/precompiles/src/native_coin_authority.rs index d1749d6..66be4fd 100644 --- a/crates/precompiles/src/native_coin_authority.rs +++ b/crates/precompiles/src/native_coin_authority.rs @@ -247,6 +247,7 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Reject minting to zero address (Zero5+) @@ -360,6 +361,7 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Reject burning from zero address (Zero5+) @@ -476,6 +478,7 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Reject transfers involving zero address (Zero5+) @@ -760,6 +763,14 @@ mod tests { } else { tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) }; + // Zero8: a delegatecall rejection charges the uniform early-revert penalty. + let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero8) + && tc.expected_revert_str == Some(ERR_DELEGATE_CALL_NOT_ALLOWED) + { + expected_gas_used.saturating_add(PRECOMPILE_EARLY_REVERT_GAS_PENALTY) + } else { + expected_gas_used + }; assert_eq!( result.gas.used(), expected_gas_used, diff --git a/crates/precompiles/src/native_coin_control.rs b/crates/precompiles/src/native_coin_control.rs index 211ff27..5597457 100644 --- a/crates/precompiles/src/native_coin_control.rs +++ b/crates/precompiles/src/native_coin_control.rs @@ -167,6 +167,18 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { hardfork_flags, )); } + // Zero8: surface the delegatecall penalty before the + // success-path floor; otherwise an authorized delegatecall + // in the 200..floor gas window OOGs before it can revert + // with the 200-gas penalty. + if hardfork_flags.is_active(ArcHardfork::Zero8) { + check_delegatecall( + NATIVE_COIN_CONTROL_ADDRESS, + &precompile_input, + &gas_counter, + hardfork_flags, + )?; + } check_gas_remaining( &gas_counter, BLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, @@ -206,6 +218,7 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { NATIVE_COIN_CONTROL_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Add to blocklist @@ -307,6 +320,18 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { hardfork_flags, )); } + // Zero8: surface the delegatecall penalty before the + // success-path floor; otherwise an authorized delegatecall + // in the 200..floor gas window OOGs before it can revert + // with the 200-gas penalty. + if hardfork_flags.is_active(ArcHardfork::Zero8) { + check_delegatecall( + NATIVE_COIN_CONTROL_ADDRESS, + &precompile_input, + &gas_counter, + hardfork_flags, + )?; + } check_gas_remaining( &gas_counter, UNBLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, @@ -346,6 +371,7 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { NATIVE_COIN_CONTROL_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Remove from blocklist @@ -458,6 +484,60 @@ mod tests { zero6_only: bool, /// If true, skip this test case for hardfork combinations with Zero6. pre_zero6_only: bool, + /// If true, skip this test case for hardfork combinations without Zero8. + /// Used for cases whose result shape only holds once the Zero8 + /// delegatecall reorder is active (penalty revert vs OOG). + zero8_only: bool, + /// If true, skip this test case for hardfork combinations with Zero8. + pre_zero8_only: bool, + } + + impl NativeCoinControlTest { + /// Whether this case should be skipped for the given hardfork combination. + fn skip_for(&self, hardfork_flags: ArcHardforkFlags) -> bool { + let zero5 = hardfork_flags.is_active(ArcHardfork::Zero5); + let zero6 = hardfork_flags.is_active(ArcHardfork::Zero6); + let zero8 = hardfork_flags.is_active(ArcHardfork::Zero8); + // ZeroX hardforks are cumulative; Zero6 implies Zero5. + let invalid_combo = zero6 && !zero5; + invalid_combo + || (self.zero6_only && !zero6) + || (self.pre_zero6_only && zero6) + || (self.zero8_only && !zero8) + || (self.pre_zero8_only && zero8) + } + } + + fn run_native_coin_control_case(tc: &NativeCoinControlTest, hardfork_flags: ArcHardforkFlags) { + let tc_name = format!("{} (hardfork_flags: {hardfork_flags:?})", tc.name); + + // Sanity check that we're not configuring test cases incorrectly + if !matches!( + tc.expected_result, + InstructionResult::Revert | InstructionResult::Return + ) { + assert!( + tc.return_data.is_none(), + "{tc_name}: expected no return data" + ); + } + + let mut ctx = mock_context(hardfork_flags); + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: tc.target_address, + bytecode_address: tc.bytecode_address, + known_bytecode: None, + caller: tc.caller, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(tc.calldata.clone()), + gas_limit: tc.gas_limit, + is_static: false, + return_memory_offset: 0..0, + }; + + let precompile_res = call_native_coin_control(&mut ctx, &inputs, hardfork_flags); + assert_precompile_result(precompile_res, tc, hardfork_flags, &tc_name); } // Test constants @@ -513,6 +593,14 @@ mod tests { } else { tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) }; + // Zero8: a delegatecall rejection charges the uniform early-revert penalty. + let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero8) + && tc.expected_revert_str == Some(ERR_DELEGATE_CALL_NOT_ALLOWED) + { + expected_gas_used.saturating_add(PRECOMPILE_EARLY_REVERT_GAS_PENALTY) + } else { + expected_gas_used + }; assert_eq!( result.gas.used(), expected_gas_used, @@ -544,6 +632,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -563,6 +653,8 @@ mod tests { zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -581,6 +673,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -597,6 +691,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -616,6 +712,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: ADDRESS_B, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -635,6 +733,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: ADDRESS_B, }, @@ -654,6 +754,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -672,6 +774,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -688,6 +792,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -707,6 +813,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -726,6 +834,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: ADDRESS_B, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -745,6 +855,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: ADDRESS_B, }, @@ -764,6 +876,8 @@ mod tests { zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -782,6 +896,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -798,6 +914,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -819,6 +937,8 @@ mod tests { zero6_gas_used: None, zero6_only: true, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -840,6 +960,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: true, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -858,6 +980,8 @@ mod tests { zero6_gas_used: None, zero6_only: true, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -876,59 +1000,106 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: true, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, + // Zero8: an authorized delegatecall with gas in the + // 200..floor window reverts with the delegatecall penalty, + // because the delegatecall check now runs before the + // success-path gas floor. + NativeCoinControlTest { + name: "blocklist() Zero8 low-gas authorized delegatecall reverts with penalty", + caller: ALLOWED_CALLER_ADDRESS, + calldata: INativeCoinControl::blocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + gas_limit: 1000, + expected_result: InstructionResult::Revert, + expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), + return_data: None, + gas_used: 0, + pre_zero5_gas_used: None, + zero6_gas_used: None, + zero6_only: true, + pre_zero6_only: false, + zero8_only: true, + pre_zero8_only: false, + target_address: ADDRESS_B, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + }, + // Pre-Zero8: the same call OOGs on the success-path floor + // before reaching the delegatecall check. Locks in that the + // reorder is Zero8-gated and does not touch live networks. + NativeCoinControlTest { + name: "blocklist() pre-Zero8 low-gas authorized delegatecall OOGs", + caller: ALLOWED_CALLER_ADDRESS, + calldata: INativeCoinControl::blocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + gas_limit: 1000, + expected_result: InstructionResult::PrecompileOOG, + expected_revert_str: None, + return_data: None, + gas_used: 0, + pre_zero5_gas_used: None, + zero6_gas_used: None, + zero6_only: false, + pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: true, + target_address: ADDRESS_B, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + }, + NativeCoinControlTest { + name: "unBlocklist() Zero8 low-gas authorized delegatecall reverts with penalty", + caller: ALLOWED_CALLER_ADDRESS, + calldata: INativeCoinControl::unBlocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + gas_limit: 1000, + expected_result: InstructionResult::Revert, + expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), + return_data: None, + gas_used: 0, + pre_zero5_gas_used: None, + zero6_gas_used: None, + zero6_only: true, + pre_zero6_only: false, + zero8_only: true, + pre_zero8_only: false, + target_address: ADDRESS_B, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + }, + NativeCoinControlTest { + name: "unBlocklist() pre-Zero8 low-gas authorized delegatecall OOGs", + caller: ALLOWED_CALLER_ADDRESS, + calldata: INativeCoinControl::unBlocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + gas_limit: 1000, + expected_result: InstructionResult::PrecompileOOG, + expected_revert_str: None, + return_data: None, + gas_used: 0, + pre_zero5_gas_used: None, + zero6_gas_used: None, + zero6_only: false, + pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: true, + target_address: ADDRESS_B, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + }, ]; for tc in cases { for hardfork_flags in ArcHardforkFlags::all_combinations() { - // ZeroX hardforks are cumulative; Zero6 implies Zero5. - if hardfork_flags.is_active(ArcHardfork::Zero6) - && !hardfork_flags.is_active(ArcHardfork::Zero5) - { - continue; - } - - if tc.zero6_only && !hardfork_flags.is_active(ArcHardfork::Zero6) { - continue; - } - if tc.pre_zero6_only && hardfork_flags.is_active(ArcHardfork::Zero6) { + if tc.skip_for(hardfork_flags) { continue; } - - let tc_name = - tc.name.to_string() + &format!(" (hardfork_flags: {:?})", hardfork_flags); - - // Sanity check that we're not configuring test cases incorrectly - match tc.expected_result { - InstructionResult::Revert | InstructionResult::Return => {} - _ => { - assert!( - tc.return_data.is_none(), - "{tc_name}: expected no return data", - ); - } - } - - let mut ctx = mock_context(hardfork_flags); - - // Prepare inputs - let inputs = CallInputs { - scheme: CallScheme::Call, - target_address: tc.target_address, - bytecode_address: tc.bytecode_address, - known_bytecode: None, - caller: tc.caller, - value: CallValue::Transfer(U256::ZERO), - input: CallInput::Bytes(tc.calldata.clone()), - gas_limit: tc.gas_limit, - is_static: false, - return_memory_offset: 0..0, - }; - - let precompile_res = call_native_coin_control(&mut ctx, &inputs, hardfork_flags); - assert_precompile_result(precompile_res, tc, hardfork_flags, &tc_name); + run_native_coin_control_case(tc, hardfork_flags); } } } diff --git a/crates/precompiles/src/system_accounting.rs b/crates/precompiles/src/system_accounting.rs index fee3f24..0f0e0cd 100644 --- a/crates/precompiles/src/system_accounting.rs +++ b/crates/precompiles/src/system_accounting.rs @@ -173,6 +173,7 @@ precompile!(run_system_accounting, precompile_input, hardfork_flags; { SYSTEM_ACCOUNTING_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Update storage @@ -631,31 +632,103 @@ mod tests { } } - #[test] - fn store_gas_values_table_tests() { - struct StoreCase { - name: &'static str, - caller: Address, - calldata: Bytes, - gas_limit: u64, - /// If set, overrides `gas_limit` when Zero6 is active. Needed when - /// the Zero6 early-revert penalty pushes required gas above the - /// Zero5 limit. - zero6_gas_limit: Option, - expected_result: InstructionResult, - expected_revert_str: Option<&'static str>, - return_data: Option, - gas_used: u64, - /// If set, overrides `gas_used` for pre-Zero5 hardforks (fixed - /// SSTORE cost vs. EIP-2929/EIP-2200 warm/cold pricing). - pre_zero5_gas_used: Option, - /// If set, overrides `gas_used` when Zero6 is active (auth reverts - /// charge `PRECOMPILE_EARLY_REVERT_GAS_PENALTY`). - zero6_gas_used: Option, - target_address: Address, - bytecode_address: Address, + struct StoreCase { + name: &'static str, + caller: Address, + calldata: Bytes, + gas_limit: u64, + /// If set, overrides `gas_limit` when Zero6 is active. Needed when + /// the Zero6 early-revert penalty pushes required gas above the + /// Zero5 limit. + zero6_gas_limit: Option, + expected_result: InstructionResult, + expected_revert_str: Option<&'static str>, + return_data: Option, + gas_used: u64, + /// If set, overrides `gas_used` for pre-Zero5 hardforks (fixed + /// SSTORE cost vs. EIP-2929/EIP-2200 warm/cold pricing). + pre_zero5_gas_used: Option, + /// If set, overrides `gas_used` when Zero6 is active (auth reverts + /// charge `PRECOMPILE_EARLY_REVERT_GAS_PENALTY`). + zero6_gas_used: Option, + target_address: Address, + bytecode_address: Address, + } + + /// Resolves the expected gas used for a `StoreCase` under the given hardfork flags. + fn expected_store_gas_used(tc: &StoreCase, hardfork_flags: ArcHardforkFlags) -> u64 { + let base = if hardfork_flags.is_active(ArcHardfork::Zero6) { + tc.zero6_gas_used.unwrap_or(tc.gas_used) + } else if hardfork_flags.is_active(ArcHardfork::Zero5) { + tc.gas_used + } else { + tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) + }; + // Zero8: a delegatecall rejection charges the uniform early-revert penalty. + if hardfork_flags.is_active(ArcHardfork::Zero8) + && tc.expected_revert_str == Some(ERR_DELEGATE_CALL_NOT_ALLOWED) + { + base.saturating_add(PRECOMPILE_EARLY_REVERT_GAS_PENALTY) + } else { + base + } + } + + /// Runs one `StoreCase` under a single hardfork combination and asserts the outcome. + /// Non-cumulative combinations (Zero6 without Zero5) are skipped. + fn run_store_gas_values_case(tc: &StoreCase, hardfork_flags: ArcHardforkFlags) { + if hardfork_flags.is_active(ArcHardfork::Zero6) + && !hardfork_flags.is_active(ArcHardfork::Zero5) + { + return; + } + + let tc_name = format!("{} (hardfork_flags: {:?})", tc.name, hardfork_flags); + + let gas_limit = if hardfork_flags.is_active(ArcHardfork::Zero6) { + tc.zero6_gas_limit.unwrap_or(tc.gas_limit) + } else { + tc.gas_limit + }; + let expected_gas_used = expected_store_gas_used(tc, hardfork_flags); + + let mut ctx = Context::mainnet(); + ctx.journal_mut() + .load_account(SYSTEM_ACCOUNTING_ADDRESS) + .expect("Unable to load system accounting account"); + + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: tc.target_address, + bytecode_address: tc.bytecode_address, + known_bytecode: None, + caller: tc.caller, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(tc.calldata.clone()), + gas_limit, + is_static: false, + return_memory_offset: 0..0, + }; + + let res = call_system_accounting(&mut ctx, &inputs, hardfork_flags) + .unwrap() + .unwrap(); + assert_eq!(res.result, tc.expected_result, "{tc_name}"); + + if let Some(expected_revert_str) = tc.expected_revert_str { + let reason = bytes_to_revert_message(res.output.as_ref()).expect("revert reason"); + assert_eq!(reason, expected_revert_str, "{tc_name}"); + } + + if let Some(expected_return) = &tc.return_data { + assert_eq!(res.output, *expected_return, "{tc_name}"); } + assert_eq!(res.gas.used(), expected_gas_used, "{tc_name}"); + } + + #[test] + fn store_gas_values_table_tests() { let bn_ok = 1024u64; let val_ok = GasValues { gasUsed: 11, @@ -816,70 +889,77 @@ 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; - } - - let tc_name = format!("{} (hardfork_flags: {:?})", tc.name, hardfork_flags); - - let gas_limit = if hardfork_flags.is_active(ArcHardfork::Zero6) { - tc.zero6_gas_limit.unwrap_or(tc.gas_limit) - } else { - tc.gas_limit - }; - - let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero6) { - tc.zero6_gas_used.unwrap_or(tc.gas_used) - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - tc.gas_used - } else { - tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) - }; - - let mut ctx = Context::mainnet(); - ctx.journal_mut() - .load_account(SYSTEM_ACCOUNTING_ADDRESS) - .expect("Unable to load system accounting account"); - - let inputs = CallInputs { - scheme: CallScheme::Call, - target_address: tc.target_address, - bytecode_address: tc.bytecode_address, - known_bytecode: None, - caller: tc.caller, - value: CallValue::Transfer(U256::ZERO), - input: CallInput::Bytes(tc.calldata.clone()), - gas_limit, - is_static: false, - return_memory_offset: 0..0, - }; - - let res = call_system_accounting(&mut ctx, &inputs, hardfork_flags) - .unwrap() - .unwrap(); - // Check result - assert_eq!(res.result, tc.expected_result, "{tc_name}"); - - // Revert string - if let Some(expected_revert_str) = tc.expected_revert_str { - let reason = - bytes_to_revert_message(res.output.as_ref()).expect("revert reason"); - assert_eq!(reason, expected_revert_str, "{tc_name}"); - } - - // Return data - if let Some(expected_return) = &tc.return_data { - assert_eq!(res.output, *expected_return, "{tc_name}"); - } - // Gas used - assert_eq!(res.gas.used(), expected_gas_used, "{tc_name}"); + run_store_gas_values_case(tc, hardfork_flags); } } } + /// A delegatecall into a stateful precompile is rejected by `check_delegatecall`. + /// Under Zero8 the rejection charges the uniform 200-gas early-revert penalty; + /// before Zero8 it reverts with no penalty. + #[test] + fn delegatecall_charges_early_revert_penalty_only_under_zero8() { + let calldata = ISystemAccounting::storeGasValuesCall { + blockNumber: 1, + gasValues: GasValues { + gasUsed: 1, + gasUsedSmoothed: 2, + nextBaseFee: 3, + }, + } + .abi_encode(); + + let gas_used_for = |hardfork_flags: ArcHardforkFlags| -> u64 { + let mut ctx = Context::mainnet(); + ctx.journal_mut() + .load_account(SYSTEM_ACCOUNTING_ADDRESS) + .expect("load system accounting account"); + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: SYSTEM_ACCOUNTING_ADDRESS, + // bytecode_address != precompile address => delegatecall shape. + bytecode_address: address!("0x0000000000000000000000000000000000000123"), + known_bytecode: None, + caller: ARC_SYSTEM_CALLER, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.clone().into()), + gas_limit: 1_000_000, + is_static: false, + return_memory_offset: 0..0, + }; + let res = call_system_accounting(&mut ctx, &inputs, hardfork_flags) + .unwrap() + .unwrap(); + assert_eq!(res.result, InstructionResult::Revert); + assert_eq!( + bytes_to_revert_message(res.output.as_ref()).expect("revert reason"), + ERR_DELEGATE_CALL_NOT_ALLOWED, + ); + res.gas.used() + }; + + let pre_zero8 = gas_used_for(ArcHardforkFlags::with(&[ + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ])); + let zero8 = gas_used_for(ArcHardforkFlags::with(&[ + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ArcHardfork::Zero8, + ])); + + assert_eq!( + pre_zero8, 0, + "pre-Zero8 delegatecall rejection charges no penalty" + ); + assert_eq!( + zero8, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + "Zero8 delegatecall rejection charges the 200-gas early-revert penalty", + ); + } + #[test] fn read_write_workflow() { let mut ctx = Context::mainnet(); diff --git a/crates/quake/scenarios/nightly-upgrade.toml b/crates/quake/scenarios/nightly-upgrade.toml index 3d60aae..c375e55 100644 --- a/crates/quake/scenarios/nightly-upgrade.toml +++ b/crates/quake/scenarios/nightly-upgrade.toml @@ -10,7 +10,7 @@ image_cl="${IMAGE_REGISTRY_URL}/arc-consensus:latest" image_el="${IMAGE_REGISTRY_URL}/arc-execution:latest" # Set the execution layer initial hardfork to the highest version supported by the image. -el_init_hardfork="zero4" +el_init_hardfork="zero7" # Upgrade versions (what we upgrade to during the test) image_cl_upgrade="arc_consensus:latest" diff --git a/crates/quake/templates/local/compose.yaml.hbs b/crates/quake/templates/local/compose.yaml.hbs index d94e7eb..9351ae7 100644 --- a/crates/quake/templates/local/compose.yaml.hbs +++ b/crates/quake/templates/local/compose.yaml.hbs @@ -321,7 +321,7 @@ services: environment: <<: *aws-base APP_ENV: ${APP_ENV:-dev} - APP_PROVIDER_ENCLAVE_CLIENT_BASEURL: http://{{name}}-signer-enclave:10350 + APP_PROVIDER_ENCLAVE_CLIENT_BASEURL: {{name}}-signer-enclave:10350 APP_PROVIDER_SECRETS_LOCALSTACK_ENABLED: false APP_SERVICE_SIGNER_KEYID: ${APP_SERVICE_SIGNER{{inc @index}}_KEYID:-dev/arc/remote-signer/{{remote_signer}}} APP_PROVIDER_AWSKMS_ARNS: ${APP_PROVIDER_AWSKMS_ARNS} @@ -356,7 +356,7 @@ services: memlock: -1 entrypoint: ["/usr/local/circle/run_enclave.dev.sh"] healthcheck: - test: ["CMD-SHELL", "curl -f --insecure http://localhost:10350/ping"] + test: ["CMD", "grpc_health_probe", "-addr=localhost:10350"] <<: *health-retry {{/if}} diff --git a/crates/types/src/commit_http.rs b/crates/types/src/commit_http.rs index 7d4d6fe..dd65b36 100644 --- a/crates/types/src/commit_http.rs +++ b/crates/types/src/commit_http.rs @@ -148,4 +148,31 @@ mod tests { let back: HttpCommitSignature = serde_json::from_str(&json).unwrap(); assert_eq!(http, back); } + + #[test] + fn http_commit_signature_serializes_as_base64_wire_format() { + let mut b = [0u8; 64]; + b[0] = 1; + b[63] = 2; + let http = HttpCommitSignature::from(CommitSignature { + address: Address::default(), + signature: Signature::from_bytes(b), + }); + + let value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&http).unwrap()).unwrap(); + let wire = value["signature"] + .as_str() + .expect("signature must serialize as a JSON string"); + + // Standard padded Base64 of the 64-byte signature, not hex. + let expected_b64 = format!("AQAA{}Ag==", "AAAA".repeat(20)); + assert_eq!( + wire, expected_b64, + "signature must be standard padded Base64" + ); + + let hex = format!("01{}02", "00".repeat(62)); + assert_ne!(wire, hex, "signature must not be hex-encoded"); + } } diff --git a/deployments/monitoring/config-blockscout/frontend/.curlrc b/deployments/monitoring/config-blockscout/frontend/.curlrc index 5bcdf03..5688b97 100644 --- a/deployments/monitoring/config-blockscout/frontend/.curlrc +++ b/deployments/monitoring/config-blockscout/frontend/.curlrc @@ -1,2 +1,2 @@ -# Disable TLS Verification for asset download; needed due to Zscaler +# Disable TLS certificate verification for asset download (needed behind TLS-inspecting proxies) insecure \ No newline at end of file diff --git a/docs/adr/0004-base-fee-validation.md b/docs/adr/0004-base-fee-validation.md index 7438c50..3be5202 100644 --- a/docs/adr/0004-base-fee-validation.md +++ b/docs/adr/0004-base-fee-validation.md @@ -5,23 +5,23 @@ | Status | Draft | | Author(s) | @asoghoian | | Created | 2026-03-03 | -| Updated | 2026-05-12 | +| Updated | 2026-06-08 | | Supersedes | - | | Superseded by | - | ## Context -On Arc, the base fee calculation combines: 1) an EIP-1559-style algorithm, 2) several at-runtime-governance-tunable values, and 3) an exponentially-smoothed view of historical gas use. +On Arc, the base fee calculation combines: 1) an EIP-1559-style algorithm, 2) several at-runtime-governance-tunable values, and 3) an exponentially-smoothed view of historical gas use. The value of 1) is that it is familiar and well-understood. The value of 2) is to enable rapid adjusting of fee parameters and strictly enforcing block gas limits through governance. The value of 3) is to dampen fee increases against sudden shocks in usage with Arc's fast blocktimes. -The combination of these makes it challenging for integrators to precisely understand the next block base fee as the calculation is more involved. To address this, Arc includes the next block base fee in the header, computed at the end of the parent's block execution, since it is fully known at that point. +The combination of these makes it challenging for integrators to precisely understand the next block base fee as the calculation is more involved. To address this, Arc includes the next block base fee in the header, computed at the end of the parent's block execution, since it is fully known at that point. Currently, the implementation has several gaps in validation. This ADR, similar to ADR-003, seeks to document solutions to these gaps. ### Gap 1: No validation of the base fee computation -There is currently no validation of the base fee calculation, just a check that the parent's header contains the next block's base fee. This is clearly insufficient -- a proposer could miscalculate a base fee, store it in the block header, and force the next proposer to use it. +There is currently no validation of the base fee calculation, just a check that the parent's header contains the next block's base fee. This is clearly insufficient -- a proposer could miscalculate a base fee, store it in the block header, and force the next proposer to use it. ### Gap 2: The current fallback behavior is complex for integrators @@ -31,7 +31,7 @@ This adds complexity as: 1) an integrator may not know how to "derive" the base ## Decision -Always include the next block base fee, use a consistent algorithm, and apply strict validation. +Always include the next block base fee, use a consistent algorithm, and apply strict validation. ### Chainspec Updates @@ -41,8 +41,8 @@ Two new types are attached to the chainspec via `base_fee_config(block_height)`: /// Bounds on the three calculation parameters from ProtocolConfig. struct BaseFeeCalcParams { alpha: u64, // EMA smoothing factor [0, 100] - k_rate: u64, // Max change rate in basis points (200 = 2%) - elasticity_multiplier: u64, // Target gas ratio in basis points (5000 = 50%) + k_rate: u64, // Base fee adjustment coefficient, in basis points (200 = 2%) + inverse_elasticity_multiplier: u64, // Target gas ratio in basis points (5000 = 50%) } /// Complete base fee configuration for a network. @@ -72,7 +72,7 @@ The two absolute bounds control the *output*: after computation and after the Pr | testnet | 1 | 20 | 100 | | mainnet | 1 | 20 | 100 | -**k_rate** — max base fee change rate per block (basis points). 200 = 2%. +**k_rate** — Base fee adjustment coefficient per block (basis points). 200 = 2%. | Network | min | default | max | |---------|-----|---------|-----| @@ -81,7 +81,7 @@ The two absolute bounds control the *output*: after computation and after the Pr | testnet | 1 | 200 | 1000 | | mainnet | 1 | 200 | 1000 | -**elasticity_multiplier** — target gas utilization (basis points). 5000 = 50%. +**inverse_elasticity_multiplier** — target gas utilization (basis points). 5000 = 50%. | Network | min | default | max | |---------|-----|---------|-----| @@ -119,14 +119,14 @@ else: calc = BaseFeeCalcParams( alpha = validated(fee_params.alpha, alpha), k_rate = validated(fee_params.kRate, k_rate), - elasticity_multiplier = validated(fee_params.elasticityMultiplier, elasticity_multiplier), - ) + inverse_elasticity_multiplier = validated(fee_params.inverseElasticityMultiplier, inverse_elasticity_multiplier), + ) # 1. Compute smoothed gas smoothed_gas = ema(parent_smoothed_gas, block_gas_used, calc.alpha) # 2. Compute next base fee -next_base_fee = arc_calc_next_block_base_fee(smoothed_gas, gas_limit, base_fee, calc.k_rate, calc.elasticity_multiplier) +next_base_fee = arc_calc_next_block_base_fee(smoothed_gas, gas_limit, base_fee, calc.k_rate, calc.inverse_elasticity_multiplier) # 3. Apply ProtocolConfig's own minBaseFee/maxBaseFee clamp (if available) if fee_params is not None: @@ -148,13 +148,8 @@ The assembler always writes `next_base_fee` from SystemAccounting to `extra_data ```python config = chainspec.base_fee_config(block_height) -# 1. Absolute bounds check +# Absolute bounds check assert config.absolute_min_base_fee <= header.base_fee <= config.absolute_max_base_fee - -# 2. Rate check (proportional, using max allowed k_rate) -if parent.number > 0: - max_delta = parent.base_fee * config.params_max.k_rate / 10000 - assert abs(header.base_fee - parent.base_fee) <= max_delta ``` **Stateful** (after block execution): diff --git a/scripts/create-release-tag.sh b/scripts/create-release-tag.sh new file mode 100755 index 0000000..e459cff --- /dev/null +++ b/scripts/create-release-tag.sh @@ -0,0 +1,670 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + create-release-tag.sh --source --release-kind patch|minor|major [--as-release-candidate] [--release-ref-prefix test-] [--base-branch main] + create-release-tag.sh --tag [--release-ref-prefix test-] [--base-branch main] + +Environment: + PUSH_TAG=false Create the tag locally but do not push it to origin. + RELEASE_REF_PREFIX + Namespace prefix used to derive release branches and tags. + Empty derives release/ and v. test- derives test-release/ + and test-v. + TAG_PREFIX Internal release tag prefix derived from RELEASE_REF_PREFIX. + RELEASE_BRANCH_PREFIX + Internal release branch prefix derived from RELEASE_REF_PREFIX. + BASE_BRANCH=main + Base branch used for minor and major releases. + MAIN_BRANCH Deprecated alias for BASE_BRANCH. + ALLOW_STALE_RELEASE_REFS=true + Continue if fetching origin refs fails. Intended only for + local/offline testing. +USAGE +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/release-config.sh +source "${SCRIPT_DIR}/release-config.sh" + +SOURCE_REF="latest" +RELEASE_KIND="" +TAG="" +AS_RELEASE_CANDIDATE=false +PUSH_TAG="${PUSH_TAG:-true}" +CREATED_NEW_TAG=false +CREATED_RELEASE_BRANCH=false +RESOLVED_SOURCE_REF="" +SOURCE_BRANCH="" +RELEASE_REF_PREFIX="${RELEASE_REF_PREFIX:-}" +BASE_BRANCH="${BASE_BRANCH:-${MAIN_BRANCH:-main}}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --source) SOURCE_REF="${2:?missing source ref}"; shift 2 ;; + --release-kind) RELEASE_KIND="${2:?missing release kind}"; shift 2 ;; + --tag) TAG="${2:?missing tag}"; shift 2 ;; + --as-release-candidate) AS_RELEASE_CANDIDATE=true; shift ;; + --release-ref-prefix) RELEASE_REF_PREFIX="${2-}"; shift 2 ;; + --base-branch) BASE_BRANCH="${2:?missing base branch}"; shift 2 ;; + --main-branch) BASE_BRANCH="${2:?missing main branch}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ -n "${RELEASE_KIND}" && -n "${TAG}" ]]; then + echo "Set either --release-kind or tag, not both" >&2 + exit 1 +fi + +if [[ -z "${RELEASE_KIND}" && -z "${TAG}" ]]; then + echo "Set release_kind or tag" >&2 + exit 1 +fi + +validate_namespace_ref() { + local label="$1" + local ref="$2" + + if ! git check-ref-format "${ref}" >/dev/null 2>&1; then + echo "Invalid ${label}: ${ref}" >&2 + exit 1 + fi +} + +TAG_PREFIX="$(release_tag_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" +RELEASE_BRANCH_PREFIX="$(release_branch_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" +BASE_BRANCH="${BASE_BRANCH:-main}" + +if [[ -z "${TAG_PREFIX}" ]]; then + echo "tag prefix cannot be empty" >&2 + exit 1 +fi +if [[ -z "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "release branch prefix cannot be empty" >&2 + exit 1 +fi +if [[ -z "${BASE_BRANCH}" ]]; then + echo "base branch cannot be empty" >&2 + exit 1 +fi +validate_namespace_ref "tag prefix" "refs/tags/${TAG_PREFIX}0.0.0" +validate_namespace_ref "release branch prefix" "refs/heads/${RELEASE_BRANCH_PREFIX}0.0" +validate_namespace_ref "base branch" "refs/heads/${BASE_BRANCH}" + +if ! git fetch origin '+refs/heads/*:refs/remotes/origin/*' --tags --prune >/dev/null 2>&1; then + if [[ "${ALLOW_STALE_RELEASE_REFS:-false}" != true ]]; then + echo "Unable to fetch origin refs; refusing to calculate a release from stale local state" >&2 + exit 1 + fi + echo "Warning: unable to fetch origin; continuing with local refs because ALLOW_STALE_RELEASE_REFS=true." >&2 +fi + +tag_version() { + local tag="$1" + local prefix="${2:-${TAG_PREFIX}}" + if [[ "${tag:0:${#prefix}}" != "${prefix}" ]]; then + return 1 + fi + printf '%s\n' "${tag:${#prefix}}" +} + +validate_tag() { + local tag="$1" + local version + + version="$(tag_version "${tag}")" || return 1 + [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$ ]] +} + +format_final_tag() { + printf '%s%s.%s.%s\n' "${TAG_PREFIX}" "$1" "$2" "$3" +} + +format_final_tag_with_prefix() { + printf '%s%s.%s.%s\n' "$1" "$2" "$3" "$4" +} + +format_rc_tag() { + printf '%s%s.%s.%s-rc%s\n' "${TAG_PREFIX}" "$1" "$2" "$3" "$4" +} + +release_line_from_branch() { + local branch="$1" + branch="${branch#origin/}" + if [[ "${branch:0:${#RELEASE_BRANCH_PREFIX}}" != "${RELEASE_BRANCH_PREFIX}" ]]; then + return 1 + fi + + local line="${branch:${#RELEASE_BRANCH_PREFIX}}" + [[ "${line}" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+|x))?$ ]] +} + +tag_points_to_ref() { + local tag="$1" + local ref="$2" + + if ! git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then + return 1 + fi + + local tag_sha ref_sha + tag_sha="$(git rev-list -n1 "${tag}")" + ref_sha="$(git rev-parse "${ref}^{commit}")" + [[ "${tag_sha}" == "${ref_sha}" ]] +} + +configure_tag_identity() { + if [[ "${GITHUB_ACTIONS:-}" != "true" ]]; then + return + fi + + if ! git config user.name >/dev/null; then + git config user.name "github-actions[bot]" + fi + if ! git config user.email >/dev/null; then + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + fi +} + +latest_release_branch() { + local branches latest + branches="$( + while IFS= read -r branch; do + branch="${branch#origin/}" + if release_line_from_branch "${branch}"; then + printf '%s\t%s\n' "${branch:${#RELEASE_BRANCH_PREFIX}}" "${branch}" + fi + done < <(git for-each-ref --format='%(refname:short)' refs/heads refs/remotes/origin 2>/dev/null) | + sort -k1,1V | + awk -F'\t' '!seen[$2]++ {print $2}' || true + )" + latest="$(printf '%s\n' "${branches}" | tail -n1)" + if [[ -z "${latest}" ]]; then + echo "No release branch found matching ${RELEASE_BRANCH_PREFIX}X.Y, ${RELEASE_BRANCH_PREFIX}X.Y.Z, or ${RELEASE_BRANCH_PREFIX}X.Y.x" >&2 + exit 1 + fi + echo "${latest}" +} + +resolve_ref() { + local ref="$1" + local kind="$2" + if [[ "${ref}" == "latest" ]]; then + if [[ "${kind}" == "patch" ]]; then + ref="$(latest_release_branch)" + echo "Resolved latest release branch to ${ref}" >&2 + else + ref="${BASE_BRANCH}" + echo "Resolved latest source branch to ${ref} for ${kind} release" >&2 + fi + fi + if git rev-parse --verify --quiet "${ref}^{commit}" >/dev/null; then + echo "${ref}" + elif git rev-parse --verify --quiet "origin/${ref}^{commit}" >/dev/null; then + echo "origin/${ref}" + else + echo "Cannot resolve ref: ${ref}" >&2 + exit 1 + fi +} + +normalize_branch_name() { + local ref="$1" + ref="${ref#origin/}" + if [[ "${ref}" == "${BASE_BRANCH}" || "${ref:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${ref}" + return + fi + + local containing + containing="$( + while IFS= read -r branch; do + branch="${branch#origin/}" + if [[ "${branch}" == "${BASE_BRANCH}" || "${branch:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${branch}" + break + fi + done < <(git branch -r --contains "${ref}" --format='%(refname:short)' 2>/dev/null) || true + )" + if [[ -z "${containing}" ]]; then + containing="$( + while IFS= read -r branch; do + if [[ "${branch}" == "${BASE_BRANCH}" || "${branch:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${branch}" + break + fi + done < <(git branch --contains "${ref}" --format='%(refname:short)' 2>/dev/null) || true + )" + fi + echo "${containing}" +} + +latest_global_tag() { + local prefix="${1:-${TAG_PREFIX}}" + local tag version final_tag + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + + version="$(tag_version "${tag}" "${prefix}")" || continue + if [[ "${version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc[0-9]+$ ]]; then + final_tag="$(format_final_tag_with_prefix "${prefix}" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}")" + if git rev-parse --verify --quiet "refs/tags/${final_tag}" >/dev/null; then + continue + fi + fi + + echo "${tag}" + return + done < <(git tag --list "${prefix}[0-9]*" --sort=-v:refname) +} + +latest_tag() { + local source="$1" + local tag_filter="$2" + local prefix="${3:-${TAG_PREFIX}}" + local tag version final_tag final_sha + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + + version="$(tag_version "${tag}" "${prefix}")" || continue + if [[ "${version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc[0-9]+$ ]]; then + final_tag="$(format_final_tag_with_prefix "${prefix}" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}")" + if git rev-parse --verify --quiet "refs/tags/${final_tag}" >/dev/null; then + final_sha="$(git rev-list -n1 "${final_tag}")" + if git merge-base --is-ancestor "${final_sha}" "${source}"; then + continue + fi + fi + fi + + echo "${tag}" + return + done < <(git tag --merged "${source}" --list "${tag_filter}" --sort=-v:refname) +} + +latest_matching_rc_tag() { + local source="$1" + local final_tag="$2" + + git tag --merged "${source}" --list "${final_tag}-rc[0-9]*" --sort=-v:refname | head -n1 +} + +latest_final_tag_for_candidate() { + local candidate_tag="$1" + local candidate_version major minor tag tag_filter + + candidate_version="$(tag_version "${candidate_tag}")" || return + if [[ ! "${candidate_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc[0-9]+)?$ ]]; then + return + fi + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + + tag_filter="${TAG_PREFIX}[0-9]*" + if [[ "${RELEASE_KIND}" == "patch" ]]; then + tag_filter="${TAG_PREFIX}${major}.${minor}.*" + fi + + if tag="$(latest_final_tag_for_prefix "${candidate_tag}" "${tag_filter}" "${TAG_PREFIX}")"; then + echo "${tag}" + return + fi + +} + +latest_final_tag_for_prefix() { + local candidate_tag="$1" + local tag_filter="$2" + local prefix="$3" + local tag version + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + [[ -n "${candidate_tag}" && "${tag}" == "${candidate_tag}" ]] && continue + + version="$(tag_version "${tag}" "${prefix}")" || continue + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "${tag}" + return + fi + done < <(git tag --list "${tag_filter}" --sort=-v:refname) + + return 1 +} + +ensure_candidate_has_diff_from_previous_final() { + local candidate_tag="$1" + local target_sha="$2" + local previous_final_tag + + previous_final_tag="$(latest_final_tag_for_candidate "${candidate_tag}")" + if [[ -z "${previous_final_tag}" ]]; then + return + fi + + if git diff --quiet --no-ext-diff "${previous_final_tag}" "${target_sha}" -- .; then + echo "No changes since ${previous_final_tag}; refusing to create ${candidate_tag} at ${target_sha}" >&2 + exit 1 + fi +} + +continues_rc_line() { + local kind="$1" + local minor="$2" + local patch="$3" + + case "${kind}" in + patch) return 0 ;; + minor) [[ "${patch}" == 0 ]] ;; + major) [[ "${minor}" == 0 && "${patch}" == 0 ]] ;; + *) echo "Invalid release kind: ${kind}" >&2; exit 1 ;; + esac +} + +next_tag() { + local kind="$1" + local source="$2" + local latest latest_version major minor patch rc latest_is_rc tag_filter branch_name base_tag latest_uses_release_prefix + + tag_filter="${TAG_PREFIX}[0-9]*" + branch_name="$(normalize_branch_name "${source}")" + if release_line_from_branch "${branch_name}"; then + tag_filter="${TAG_PREFIX}${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.*" + fi + + if [[ "${kind}" == "patch" ]]; then + latest="$(latest_tag "${source}" "${tag_filter}")" + else + latest="$(latest_global_tag)" + fi + base_tag="$(format_final_tag 0 0 0)" + latest="${latest:-${base_tag}}" + latest_version="$(tag_version "${latest}")" + latest_uses_release_prefix=false + if tag_version "${latest}" "${TAG_PREFIX}" >/dev/null 2>&1; then + latest_uses_release_prefix=true + fi + + latest_is_rc=false + rc=0 + if [[ "${latest_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + rc="${BASH_REMATCH[4]}" + latest_is_rc=true + elif [[ "${latest_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + else + echo "Latest tag has unsupported format: ${latest}" >&2 + exit 1 + fi + + if [[ "${AS_RELEASE_CANDIDATE}" == true ]]; then + if [[ "${latest_uses_release_prefix}" == true && "${latest_is_rc}" == true ]] && continues_rc_line "${kind}" "${minor}" "${patch}"; then + if tag_points_to_ref "${latest}" "${source}"; then + echo "Reusing ${latest}; source commit is already tagged" >&2 + printf '%s\n' "${latest}" + return + fi + + format_rc_tag "${major}" "${minor}" "${patch}" "$((rc + 1))" + return + fi + + case "${kind}" in + patch) patch=$((patch + 1)) ;; + minor) minor=$((minor + 1)); patch=0 ;; + major) major=$((major + 1)); minor=0; patch=0 ;; + *) echo "Invalid release kind: ${kind}" >&2; exit 1 ;; + esac + format_rc_tag "${major}" "${minor}" "${patch}" 1 + return + fi + + if [[ "${latest_uses_release_prefix}" == true && "${kind}" == "patch" && "${latest}" != "${base_tag}" && "${latest_is_rc}" == false ]] && tag_points_to_ref "${latest}" "${source}"; then + echo "Reusing ${latest}; source commit is already tagged" >&2 + printf '%s\n' "${latest}" + return + fi + + if [[ "${latest_uses_release_prefix}" == true && "${latest_is_rc}" == true ]] && continues_rc_line "${kind}" "${minor}" "${patch}"; then + echo "Promoting ${latest} to $(format_final_tag "${major}" "${minor}" "${patch}")" >&2 + format_final_tag "${major}" "${minor}" "${patch}" + return + fi + + case "${kind}" in + patch) patch=$((patch + 1)) ;; + minor) minor=$((minor + 1)); patch=0 ;; + major) major=$((major + 1)); minor=0; patch=0 ;; + *) echo "Invalid release kind: ${kind}" >&2; exit 1 ;; + esac + + format_final_tag "${major}" "${minor}" "${patch}" +} + +next_available_rc_tag() { + local tag="$1" + local version major minor patch rc + + while git rev-parse --verify "refs/tags/${tag}" >/dev/null 2>&1; do + version="$(tag_version "${tag}")" || { + echo "Tag already exists: ${tag}" >&2 + exit 1 + } + if [[ ! "${version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc([0-9]+)$ ]]; then + echo "Tag already exists: ${tag}" >&2 + exit 1 + fi + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + rc="${BASH_REMATCH[4]}" + tag="$(format_rc_tag "${major}" "${minor}" "${patch}" "$((rc + 1))")" + done + + echo "${tag}" +} + +ensure_next_line_release_branch() { + local release_branch="$1" + local target_sha="$2" + + if [[ -z "${RELEASE_KIND}" || "${RELEASE_KIND}" == "patch" ]]; then + return + fi + if [[ -z "${release_branch}" || "${SOURCE_BRANCH}" == "${release_branch}" ]]; then + return + fi + + if git rev-parse --verify --quiet "refs/remotes/origin/${release_branch}^{commit}" >/dev/null; then + if git merge-base --is-ancestor "${target_sha}" "origin/${release_branch}"; then + echo "Release branch already exists for ${TAG}: ${release_branch}" >&2 + return + fi + + echo "Release branch ${release_branch} exists but does not contain ${TAG}@${target_sha}" >&2 + exit 1 + fi + + if git rev-parse --verify --quiet "refs/heads/${release_branch}^{commit}" >/dev/null; then + if ! git merge-base --is-ancestor "${target_sha}" "${release_branch}"; then + echo "Local release branch ${release_branch} exists but does not contain ${TAG}@${target_sha}" >&2 + exit 1 + fi + else + git branch "${release_branch}" "${target_sha}" + fi + + CREATED_RELEASE_BRANCH=true +} + +ref_is_contained_by_release_branch() { + local ref="$1" + local branch + + while IFS= read -r branch; do + branch="${branch#origin/}" + if release_line_from_branch "${branch}"; then + return 0 + fi + done < <(git branch -r --contains "${ref}" --format='%(refname:short)' 2>/dev/null) + + while IFS= read -r branch; do + if release_line_from_branch "${branch}"; then + return 0 + fi + done < <(git branch --contains "${ref}" --format='%(refname:short)' 2>/dev/null) + + return 1 +} + +validate_release_source() { + local kind="$1" + local source_arg="$2" + local resolved_source_ref="$3" + local source_branch="$4" + local source_arg_branch="${source_arg#origin/}" + + if [[ "${kind}" == "patch" ]]; then + if [[ "${source_arg_branch}" == "${BASE_BRANCH}" ]]; then + echo "patch releases must start from ${RELEASE_BRANCH_PREFIX}X.Y or an explicit commit contained by a ${RELEASE_BRANCH_PREFIX}X.Y branch, not ${source_arg}" >&2 + exit 1 + fi + + if release_line_from_branch "${source_branch}" || + release_line_from_branch "${source_arg_branch}" || + ref_is_contained_by_release_branch "${resolved_source_ref}"; then + return + fi + + echo "patch releases must start from ${RELEASE_BRANCH_PREFIX}X.Y or an explicit commit contained by a ${RELEASE_BRANCH_PREFIX}X.Y branch, not ${source_arg}" >&2 + exit 1 + fi + + if release_line_from_branch "${source_branch}"; then + echo "${kind} releases must start from ${BASE_BRANCH} or an explicit ${BASE_BRANCH} commit, not ${source_branch}" >&2 + exit 1 + fi + + if [[ "${source_arg_branch:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${kind} releases must not use release branch source_ref=${source_arg}" >&2 + exit 1 + fi +} + +push_created_refs() { + local refs=() + + if [[ "${PUSH_TAG}" != true ]]; then + return + fi + + if [[ "${CREATED_NEW_TAG}" == true ]]; then + refs+=("refs/tags/${TAG}") + fi + if [[ "${CREATED_RELEASE_BRANCH}" == true ]]; then + refs+=("refs/heads/${RELEASE_BRANCH}") + fi + + if [[ ${#refs[@]} -gt 0 ]]; then + git push origin "${refs[@]}" + fi +} + +if [[ -n "${RELEASE_KIND}" ]]; then + RESOLVED_SOURCE_REF="$(resolve_ref "${SOURCE_REF}" "${RELEASE_KIND}")" + SOURCE_BRANCH="$(normalize_branch_name "${RESOLVED_SOURCE_REF}")" + validate_release_source "${RELEASE_KIND}" "${SOURCE_REF}" "${RESOLVED_SOURCE_REF}" "${SOURCE_BRANCH}" + TAG="$(next_tag "${RELEASE_KIND}" "${RESOLVED_SOURCE_REF}")" + TAG_TARGET_REF="${RESOLVED_SOURCE_REF}" + if [[ "${AS_RELEASE_CANDIDATE}" == true ]]; then + if ! tag_points_to_ref "${TAG}" "${RESOLVED_SOURCE_REF}"; then + TAG="$(next_available_rc_tag "${TAG}")" + fi + TAG_TARGET_REF="${RESOLVED_SOURCE_REF}" + fi + validate_tag "${TAG}" || { echo "Generated invalid tag: ${TAG}" >&2; exit 1; } + if git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then + TAG_TARGET_SHA="$(git rev-parse "${TAG_TARGET_REF}^{commit}")" + ensure_candidate_has_diff_from_previous_final "${TAG}" "${TAG_TARGET_SHA}" + if tag_points_to_ref "${TAG}" "${TAG_TARGET_REF}"; then + echo "Tag already exists at target commit: ${TAG}" >&2 + else + echo "Tag already exists: ${TAG}" >&2 + exit 1 + fi + else + TAG_VERSION="$(tag_version "${TAG}")" + if [[ "${AS_RELEASE_CANDIDATE}" != true && "${TAG_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + PROMOTED_RC_TAG="$(latest_matching_rc_tag "${RESOLVED_SOURCE_REF}" "${TAG}")" + if [[ -n "${PROMOTED_RC_TAG}" ]]; then + TAG_TARGET_REF="${PROMOTED_RC_TAG}" + fi + fi + configure_tag_identity + TAG_TARGET_SHA="$(git rev-parse "${TAG_TARGET_REF}^{commit}")" + ensure_candidate_has_diff_from_previous_final "${TAG}" "${TAG_TARGET_SHA}" + git -c tag.gpgSign=false tag -a "${TAG}" -m "Release ${TAG}" "${TAG_TARGET_SHA}" + CREATED_NEW_TAG=true + fi +else + validate_tag "${TAG}" || { echo "Invalid tag: ${TAG}" >&2; exit 1; } + if ! git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Explicit tag does not exist: ${TAG}" >&2 + exit 1 + fi + RESOLVED_SOURCE_REF="${TAG}" + SOURCE_BRANCH="$(normalize_branch_name "${TAG}")" +fi + +SHA="$(git rev-list -n1 "${TAG}")" +SHORT_SHA="$(git rev-parse --short=8 "${SHA}")" +VERSION="$(tag_version "${TAG}")" +IS_RELEASE_CANDIDATE=false +RELEASE_BRANCH="" +if [[ "${TAG}" =~ -rc[0-9]+$ ]]; then + IS_RELEASE_CANDIDATE=true +fi +if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+(-rc[0-9]+)?$ ]]; then + RELEASE_BRANCH="${RELEASE_BRANCH_PREFIX}${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" +fi + +if [[ -n "${RELEASE_KIND}" ]]; then + ensure_next_line_release_branch "${RELEASE_BRANCH}" "${SHA}" + push_created_refs +fi + +CHANNEL="preview" +if git merge-base --is-ancestor "${SHA}" "origin/${BASE_BRANCH}" 2>/dev/null || git merge-base --is-ancestor "${SHA}" "${BASE_BRANCH}" 2>/dev/null; then + CHANNEL="latest" +elif git branch -r --contains "${SHA}" --format='%(refname:short)' 2>/dev/null | grep -qF "origin/${RELEASE_BRANCH_PREFIX}"; then + CHANNEL="release" +elif git branch --contains "${SHA}" --format='%(refname:short)' 2>/dev/null | grep -qF "${RELEASE_BRANCH_PREFIX}"; then + CHANNEL="release" +fi + +{ + echo "tag=${TAG}" + echo "sha=${SHA}" + echo "short_sha=${SHORT_SHA}" + echo "version=${VERSION}" + echo "is_release_candidate=${IS_RELEASE_CANDIDATE}" + echo "channel=${CHANNEL}" + echo "created_new_tag=${CREATED_NEW_TAG}" + echo "created_release_branch=${CREATED_RELEASE_BRANCH}" + echo "source_ref=${RESOLVED_SOURCE_REF}" + echo "source_branch=${SOURCE_BRANCH}" + echo "release_branch=${RELEASE_BRANCH}" +} > release.env + +cat release.env diff --git a/scripts/genesis/genesis.ts b/scripts/genesis/genesis.ts index 2bb3cfc..34cf3aa 100644 --- a/scripts/genesis/genesis.ts +++ b/scripts/genesis/genesis.ts @@ -92,6 +92,7 @@ export const schemaGenesisConfig = z zero5Block: z.number().optional(), zero6Block: z.number().optional(), zero7Time: z.number().optional(), + zero8Time: z.number().optional(), osakaTime: z.number().optional(), }) .optional(), @@ -127,7 +128,7 @@ export const schemaGenesisConfig = z export type GenesisConfig = z.infer // Defines hardfork name, this is used for genesis builder command line arguments. -export const hardforkNameSchema = z.enum(['zero3', 'zero4', 'zero5', 'zero6', 'zero7']) +export const hardforkNameSchema = z.enum(['zero3', 'zero4', 'zero5', 'zero6', 'zero7', 'zero8']) // Defines the mapping from hardfork name to genesis hardforks initialize setting. export function initialHardforksByName(hardforkName: z.infer): GenesisConfig['hardforks'] { @@ -137,6 +138,15 @@ export function initialHardforksByName(hardforkName: z.infer 0 ? { - validatorNames: parsedValidatorNames, - outputControllersConfig: outputPathWithSuffix('controllers-config'), - } + validatorNames: parsedValidatorNames, + outputControllersConfig: outputPathWithSuffix('controllers-config'), + } : {}), ...(parsedVotingPowers && parsedVotingPowers.length > 0 ? { votingPowers: parsedVotingPowers } : {}), outputGenesisConfig: outputPathWithSuffix('config'), diff --git a/scripts/release-config.sh b/scripts/release-config.sh index 5a5a053..3498ca1 100644 --- a/scripts/release-config.sh +++ b/scripts/release-config.sh @@ -1,17 +1,29 @@ #!/usr/bin/env bash +release_tag_prefix_from_ref_prefix() { + local ref_prefix="${1:-}" + + printf '%sv\n' "${ref_prefix}" +} + +release_branch_prefix_from_ref_prefix() { + local ref_prefix="${1:-}" + + printf '%srelease/\n' "${ref_prefix}" +} + release_docker_image_version_from_tag() { local tag="$1" - local version + local image_version - if [[ ! "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?)$ ]]; then + if [[ ! "${tag}" =~ ^([A-Za-z0-9._/-]*[-/])?v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then return 1 fi - version="${BASH_REMATCH[1]}" - if [[ ! "${version}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + image_version="${tag//\//-}" + if [[ ! "${image_version}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then return 1 fi - printf '%s\n' "${version}" + printf '%s\n' "${image_version}" } diff --git a/scripts/release-package.sh b/scripts/release-package.sh index 831bce7..6974c54 100755 --- a/scripts/release-package.sh +++ b/scripts/release-package.sh @@ -7,11 +7,12 @@ set -euo pipefail TAG="${1:?Usage: release-package.sh [TARGET]}" TARGET="${2:-$(rustc -vV | awk '/^host:/ {print $2}')}" +ASSET_TAG="${TAG//\//-}" BINARIES=(arc-node-execution arc-node-consensus arc-snapshots) BUILD_DIR="target/release" OUT_DIR="release-assets" -ARCHIVE_NAME="arc-node-${TAG}-${TARGET}.tar.gz" +ARCHIVE_NAME="arc-node-${ASSET_TAG}-${TARGET}.tar.gz" mkdir -p "$OUT_DIR" diff --git a/scripts/release-refs.sh b/scripts/release-refs.sh new file mode 100755 index 0000000..e780c13 --- /dev/null +++ b/scripts/release-refs.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + release-refs.sh --tag vX.Y.Z[-rcN] [--release-ref-prefix test-] + +Outputs shell-style key=value metadata for the release tag. +USAGE +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/release-config.sh +source "${SCRIPT_DIR}/release-config.sh" + +TAG="" +RELEASE_REF_PREFIX="${RELEASE_REF_PREFIX:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) TAG="${2:?missing tag}"; shift 2 ;; + --release-ref-prefix) RELEASE_REF_PREFIX="${2-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ -z "${TAG}" ]]; then + echo "Set --tag" >&2 + exit 1 +fi + +TAG_PREFIX="$(release_tag_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" +RELEASE_BRANCH_PREFIX="$(release_branch_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" + +if [[ -z "${TAG_PREFIX}" ]]; then + echo "tag prefix cannot be empty" >&2 + exit 1 +fi +if [[ -z "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "release branch prefix cannot be empty" >&2 + exit 1 +fi +if ! git check-ref-format "refs/tags/${TAG_PREFIX}0.0.0" >/dev/null 2>&1; then + echo "Invalid tag prefix: ${TAG_PREFIX}" >&2 + exit 1 +fi +if ! git check-ref-format "refs/heads/${RELEASE_BRANCH_PREFIX}0.0" >/dev/null 2>&1; then + echo "Invalid release branch prefix: ${RELEASE_BRANCH_PREFIX}" >&2 + exit 1 +fi + +if [[ "${TAG:0:${#TAG_PREFIX}}" != "${TAG_PREFIX}" ]]; then + echo "Invalid release tag: ${TAG} does not start with ${TAG_PREFIX}" >&2 + exit 1 +fi + +VERSION="${TAG:${#TAG_PREFIX}}" +if [[ ! "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc([0-9]+))?$ ]]; then + echo "Invalid release tag: ${TAG}" >&2 + exit 1 +fi + +MAJOR="${BASH_REMATCH[1]}" +MINOR="${BASH_REMATCH[2]}" +PATCH="${BASH_REMATCH[3]}" +RC="${BASH_REMATCH[5]:-}" +IS_RELEASE_CANDIDATE=false +if [[ -n "${RC}" ]]; then + IS_RELEASE_CANDIDATE=true +fi +RELEASE_BRANCH="${RELEASE_BRANCH_PREFIX}${MAJOR}.${MINOR}" +COPYBARA_BRANCH_VERSION="${TAG//\//-}" +COPYBARA_BRANCH_VERSION="${COPYBARA_BRANCH_VERSION//./_}" + +{ + echo "tag=${TAG}" + echo "version=${VERSION}" + echo "major=${MAJOR}" + echo "minor=${MINOR}" + echo "patch=${PATCH}" + echo "rc=${RC}" + echo "is_release_candidate=${IS_RELEASE_CANDIDATE}" + echo "release_branch=${RELEASE_BRANCH}" + echo "copybara_pr_branch=sync/${COPYBARA_BRANCH_VERSION}" +} diff --git a/scripts/scenarios/nightly-chaos-testing.sh b/scripts/scenarios/nightly-chaos-testing.sh index 15356f7..e6cf156 100755 --- a/scripts/scenarios/nightly-chaos-testing.sh +++ b/scripts/scenarios/nightly-chaos-testing.sh @@ -71,9 +71,16 @@ QUAKE="./target/debug/quake" echo "[1/6] Building (genesis, Docker images, quake)..." make genesis -make build-docker - -cargo build --bin quake +# When QUAKE_SKIP_BUILD is set, the caller has already provided the +# arc_execution:latest / arc_consensus:latest images and the quake binary at +# target/debug/quake (e.g. restored from a build cache), so skip the expensive +# node image and quake builds. +if [[ -n "${QUAKE_SKIP_BUILD:-}" ]]; then + echo "QUAKE_SKIP_BUILD set: reusing prebuilt node images and quake binary, skipping build" +else + make build-docker + cargo build --bin quake +fi echo "[2/6] Running quake cleanup..." "$QUAKE" --seed "$SEED" -f "$SCENARIO" clean --all 2>/dev/null diff --git a/scripts/scenarios/nightly-random-manifests.sh b/scripts/scenarios/nightly-random-manifests.sh index 86e71b9..a7579cf 100644 --- a/scripts/scenarios/nightly-random-manifests.sh +++ b/scripts/scenarios/nightly-random-manifests.sh @@ -75,8 +75,17 @@ run_phase() { } echo "[1/3] Building (genesis, Docker images, quake)..." -make genesis build-docker -cargo build --bin quake +make genesis +# When QUAKE_SKIP_BUILD is set, the caller has already provided the +# arc_execution:latest / arc_consensus:latest images and the quake binary at +# target/debug/quake (e.g. restored from a build cache), so skip the expensive +# node image and quake builds. +if [[ -n "${QUAKE_SKIP_BUILD:-}" ]]; then + echo "QUAKE_SKIP_BUILD set: reusing prebuilt node images and quake binary, skipping build" +else + make build-docker + cargo build --bin quake +fi echo "[2/3] Generating random manifests..." if [[ -n "${QUAKE_GENERATE_SEED:-}" ]]; then diff --git a/scripts/scenarios/nightly-upgrade.sh b/scripts/scenarios/nightly-upgrade.sh index 9f85edd..6c257be 100755 --- a/scripts/scenarios/nightly-upgrade.sh +++ b/scripts/scenarios/nightly-upgrade.sh @@ -31,7 +31,7 @@ echo "Repository root: $REPO_ROOT" SCENARIO="${1:-crates/quake/scenarios/nightly-upgrade.toml}" LOAD_DURATION="${2:-90}" LOAD_RATE="${3:-100}" -NEXT_HARDFORK_NAME="${4:-zero6}" +NEXT_HARDFORK_NAME="${4:-zero8}" UPGRADE_TO_HARDFORK_BLOCKS=60 # block-based hardfork: activate this many blocks ahead UPGRADE_TO_HARDFORK_SECONDS=60 # timestamp-based hardfork: activate this many seconds ahead UPGRADE_TO_OSAKA_SECONDS=300 # osakaTime is always timestamp-patched; activate this many seconds ahead