From 76c26d32c72bc6cafe812da93215372af851e016 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:44:54 -0700 Subject: [PATCH 01/18] chore(release): set version to 1.5.1 (#5875) Co-authored-by: github-actions[bot] --- Cargo.lock | 304 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 153 insertions(+), 153 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d63a89b4e1..97abbb8176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2047,7 +2047,7 @@ dependencies = [ [[package]] name = "audit-archiver" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "audit-archiver-lib", @@ -2064,7 +2064,7 @@ dependencies = [ [[package]] name = "audit-archiver-lib" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "async-trait", @@ -2889,7 +2889,7 @@ dependencies = [ [[package]] name = "base" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "axum 0.8.9", @@ -2932,7 +2932,7 @@ dependencies = [ [[package]] name = "base-acceptance" -version = "1.5.0" +version = "1.5.1" dependencies = [ "chrono", "clap", @@ -2948,7 +2948,7 @@ dependencies = [ [[package]] name = "base-acceptance-cli" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-acceptance", "clap", @@ -2957,7 +2957,7 @@ dependencies = [ [[package]] name = "base-action-harness" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3024,7 +3024,7 @@ dependencies = [ [[package]] name = "base-balance-monitor" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-network 2.4.1", "alloy-node-bindings", @@ -3037,7 +3037,7 @@ dependencies = [ [[package]] name = "base-batcher-admin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-batcher-core", "base-runtime", @@ -3051,7 +3051,7 @@ dependencies = [ [[package]] name = "base-batcher-cli" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-signer-local 2.4.1", @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "base-batcher-core" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -3097,7 +3097,7 @@ dependencies = [ [[package]] name = "base-batcher-encoder" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3122,7 +3122,7 @@ dependencies = [ [[package]] name = "base-batcher-service" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-node-bindings", "alloy-primitives", @@ -3162,7 +3162,7 @@ dependencies = [ [[package]] name = "base-batcher-source" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "base-blobs" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -3191,7 +3191,7 @@ dependencies = [ [[package]] name = "base-builder-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-builder-cli", "base-builder-core", @@ -3210,7 +3210,7 @@ dependencies = [ [[package]] name = "base-builder-cli" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "base-builder-core", @@ -3227,7 +3227,7 @@ dependencies = [ [[package]] name = "base-builder-core" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-contract", @@ -3356,7 +3356,7 @@ dependencies = [ [[package]] name = "base-builder-metering" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "base-builder-core", @@ -3369,7 +3369,7 @@ dependencies = [ [[package]] name = "base-builder-multiplex" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3411,7 +3411,7 @@ dependencies = [ [[package]] name = "base-builder-publish" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-metrics", "base-ring-buffer", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "base-bundles" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -3447,7 +3447,7 @@ dependencies = [ [[package]] name = "base-challenger" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3492,7 +3492,7 @@ dependencies = [ [[package]] name = "base-challenger-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-challenger", "base-cli-utils", @@ -3503,7 +3503,7 @@ dependencies = [ [[package]] name = "base-challenger-e2e" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-node-bindings", @@ -3531,7 +3531,7 @@ dependencies = [ [[package]] name = "base-challenger-e2e-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-challenger-e2e", "tokio", @@ -3541,7 +3541,7 @@ dependencies = [ [[package]] name = "base-cli-utils" -version = "1.5.0" +version = "1.5.1" dependencies = [ "backtrace", "base-metrics", @@ -3568,7 +3568,7 @@ dependencies = [ [[package]] name = "base-common-chains" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "alloy-eips 2.4.1", @@ -3583,7 +3583,7 @@ dependencies = [ [[package]] name = "base-common-consensus" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "base-common-eip8130" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -3629,7 +3629,7 @@ dependencies = [ [[package]] name = "base-common-evm" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3660,7 +3660,7 @@ dependencies = [ [[package]] name = "base-common-evm2" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3679,7 +3679,7 @@ dependencies = [ [[package]] name = "base-common-flashblocks" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-rpc-types-engine", @@ -3697,7 +3697,7 @@ dependencies = [ [[package]] name = "base-common-flz" -version = "1.5.0" +version = "1.5.1" dependencies = [ "criterion", "hex-literal 1.1.0", @@ -3707,7 +3707,7 @@ dependencies = [ [[package]] name = "base-common-genesis" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -3729,7 +3729,7 @@ dependencies = [ [[package]] name = "base-common-l1-fees" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "base-common-flz", @@ -3738,7 +3738,7 @@ dependencies = [ [[package]] name = "base-common-network" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-network 2.4.1", @@ -3755,7 +3755,7 @@ dependencies = [ [[package]] name = "base-common-precompiles" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-evm", "alloy-primitives", @@ -3773,7 +3773,7 @@ dependencies = [ [[package]] name = "base-common-rpc-types" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3800,7 +3800,7 @@ dependencies = [ [[package]] name = "base-common-rpc-types-engine" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3823,7 +3823,7 @@ dependencies = [ [[package]] name = "base-common-signer" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3843,7 +3843,7 @@ dependencies = [ [[package]] name = "base-comp" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-common-genesis", "base-protocol", @@ -3854,7 +3854,7 @@ dependencies = [ [[package]] name = "base-consensus" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-cli-utils", "base-consensus-cli", @@ -3863,7 +3863,7 @@ dependencies = [ [[package]] name = "base-consensus-cli" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "alloy-genesis", @@ -3912,7 +3912,7 @@ dependencies = [ [[package]] name = "base-consensus-derive" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3943,7 +3943,7 @@ dependencies = [ [[package]] name = "base-consensus-disc" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-rlp", "backon", @@ -3966,7 +3966,7 @@ dependencies = [ [[package]] name = "base-consensus-engine" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4013,7 +4013,7 @@ dependencies = [ [[package]] name = "base-consensus-gossip" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -4053,7 +4053,7 @@ dependencies = [ [[package]] name = "base-consensus-node" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -4130,7 +4130,7 @@ dependencies = [ [[package]] name = "base-consensus-peers" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "base-consensus-providers" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4193,7 +4193,7 @@ dependencies = [ [[package]] name = "base-consensus-rpc" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -4222,7 +4222,7 @@ dependencies = [ [[package]] name = "base-consensus-safedb" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -4238,7 +4238,7 @@ dependencies = [ [[package]] name = "base-consensus-sources" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-rpc-client", @@ -4260,7 +4260,7 @@ dependencies = [ [[package]] name = "base-consensus-upgrades" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "base-execution-chainspec" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -4297,7 +4297,7 @@ dependencies = [ [[package]] name = "base-execution-cli" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4375,7 +4375,7 @@ dependencies = [ [[package]] name = "base-execution-consensus" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -4408,7 +4408,7 @@ dependencies = [ [[package]] name = "base-execution-eip8130" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -4427,7 +4427,7 @@ dependencies = [ [[package]] name = "base-execution-eip8130-rpc" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4456,7 +4456,7 @@ dependencies = [ [[package]] name = "base-execution-eip8130-rpc-node" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-genesis", @@ -4481,7 +4481,7 @@ dependencies = [ [[package]] name = "base-execution-evm" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4513,7 +4513,7 @@ dependencies = [ [[package]] name = "base-execution-exex" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4539,7 +4539,7 @@ dependencies = [ [[package]] name = "base-execution-payload-builder" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4591,7 +4591,7 @@ dependencies = [ [[package]] name = "base-execution-rpc" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4668,7 +4668,7 @@ dependencies = [ [[package]] name = "base-execution-trie" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4722,7 +4722,7 @@ dependencies = [ [[package]] name = "base-execution-txpool" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4776,7 +4776,7 @@ dependencies = [ [[package]] name = "base-flashblocks" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4834,7 +4834,7 @@ dependencies = [ [[package]] name = "base-flashblocks-node" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-contract", @@ -4889,7 +4889,7 @@ dependencies = [ [[package]] name = "base-health" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-transport-http", "async-trait", @@ -4911,7 +4911,7 @@ dependencies = [ [[package]] name = "base-jwt" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-provider", @@ -4927,7 +4927,7 @@ dependencies = [ [[package]] name = "base-l1-head" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-provider", @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "base-load-tester-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-network 2.4.1", "alloy-primitives", @@ -4962,7 +4962,7 @@ dependencies = [ [[package]] name = "base-load-tests" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5005,7 +5005,7 @@ dependencies = [ [[package]] name = "base-metering" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5051,7 +5051,7 @@ dependencies = [ [[package]] name = "base-metrics" -version = "1.5.0" +version = "1.5.1" dependencies = [ "ctor", "metrics", @@ -5061,7 +5061,7 @@ dependencies = [ [[package]] name = "base-node-core" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "base-node-runner" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-genesis", @@ -5186,7 +5186,7 @@ dependencies = [ [[package]] name = "base-observability-events" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "base-metrics", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "base-optimism-rpc" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-network 2.4.1", @@ -5218,7 +5218,7 @@ dependencies = [ [[package]] name = "base-precompile-macros" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "proc-macro2", @@ -5228,7 +5228,7 @@ dependencies = [ [[package]] name = "base-precompile-storage" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-evm", "alloy-primitives", @@ -5244,7 +5244,7 @@ dependencies = [ [[package]] name = "base-proof" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "base-proof-client" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-evm", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "base-proof-contracts" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-contract", "alloy-primitives", @@ -5323,7 +5323,7 @@ dependencies = [ [[package]] name = "base-proof-driver" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-evm", @@ -5343,7 +5343,7 @@ dependencies = [ [[package]] name = "base-proof-executor" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5377,7 +5377,7 @@ dependencies = [ [[package]] name = "base-proof-host" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "base-proof-mpt" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -5441,7 +5441,7 @@ dependencies = [ [[package]] name = "base-proof-preimage" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "async-channel", @@ -5455,7 +5455,7 @@ dependencies = [ [[package]] name = "base-proof-primitives" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "alloy-eips 2.4.1", @@ -5473,7 +5473,7 @@ dependencies = [ [[package]] name = "base-proof-rpc" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-network 2.4.1", @@ -5502,7 +5502,7 @@ dependencies = [ [[package]] name = "base-proof-submission" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -5518,11 +5518,11 @@ dependencies = [ [[package]] name = "base-proof-succinct-elfs" -version = "1.5.0" +version = "1.5.1" [[package]] name = "base-proof-succinct-scripts" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "anyhow", @@ -5538,7 +5538,7 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-enclave" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-chains", "alloy-eips 2.4.1", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-host" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-genesis", "alloy-primitives", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-verifier" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -5620,7 +5620,7 @@ dependencies = [ [[package]] name = "base-proof-tee-registrar" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -5662,7 +5662,7 @@ dependencies = [ [[package]] name = "base-proof-tee-registrar-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-signer-local 2.4.1", @@ -5680,7 +5680,7 @@ dependencies = [ [[package]] name = "base-proof-worker" -version = "1.5.0" +version = "1.5.1" dependencies = [ "async-trait", "backon", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "base-proof-zk-backend" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -5731,7 +5731,7 @@ dependencies = [ [[package]] name = "base-proof-zk-host" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "async-trait", @@ -5748,7 +5748,7 @@ dependencies = [ [[package]] name = "base-proof-zk-utils" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5785,7 +5785,7 @@ dependencies = [ [[package]] name = "base-proof-zk-witness" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5820,7 +5820,7 @@ dependencies = [ [[package]] name = "base-proofs-extension" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-execution-exex", "base-execution-rpc", @@ -5837,7 +5837,7 @@ dependencies = [ [[package]] name = "base-proposer" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5879,7 +5879,7 @@ dependencies = [ [[package]] name = "base-proposer-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-cli-utils", "base-proposer", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "base-protocol" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloc-no-stdlib", "alloy-consensus 2.4.1", @@ -5927,7 +5927,7 @@ dependencies = [ [[package]] name = "base-prover-nitro-enclave" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-proof-tee-nitro-enclave", "eyre", @@ -5936,7 +5936,7 @@ dependencies = [ [[package]] name = "base-prover-nitro-host" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "base-cli-utils", @@ -5958,7 +5958,7 @@ dependencies = [ [[package]] name = "base-prover-service" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "base-prover-service-db", @@ -5976,7 +5976,7 @@ dependencies = [ [[package]] name = "base-prover-service-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-cli-utils", "base-prover-service", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "base-prover-service-client" -version = "1.5.0" +version = "1.5.1" dependencies = [ "async-trait", "backon", @@ -6010,7 +6010,7 @@ dependencies = [ [[package]] name = "base-prover-service-db" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "anyhow", @@ -6029,7 +6029,7 @@ dependencies = [ [[package]] name = "base-prover-service-protocol" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "base-proof-primitives", @@ -6042,7 +6042,7 @@ dependencies = [ [[package]] name = "base-prover-zk-host" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-cli-utils", "base-proof-worker", @@ -6062,7 +6062,7 @@ dependencies = [ [[package]] name = "base-reth-cli" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "base-common-chains", @@ -6081,7 +6081,7 @@ dependencies = [ [[package]] name = "base-reth-node" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-cli-utils", "base-execution-cli", @@ -6092,18 +6092,18 @@ dependencies = [ [[package]] name = "base-retry" -version = "1.5.0" +version = "1.5.1" dependencies = [ "backon", ] [[package]] name = "base-ring-buffer" -version = "1.5.0" +version = "1.5.1" [[package]] name = "base-roxy" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -6117,7 +6117,7 @@ dependencies = [ [[package]] name = "base-roxy-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "base-cli-utils", @@ -6130,7 +6130,7 @@ dependencies = [ [[package]] name = "base-runtime" -version = "1.5.0" +version = "1.5.1" dependencies = [ "futures", "rand 0.9.5", @@ -6142,7 +6142,7 @@ dependencies = [ [[package]] name = "base-shadow-indexer" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6176,7 +6176,7 @@ dependencies = [ [[package]] name = "base-shadow-indexer-db" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -6193,7 +6193,7 @@ dependencies = [ [[package]] name = "base-shadow-metrics" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -6212,7 +6212,7 @@ dependencies = [ [[package]] name = "base-shadow-metrics-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -6227,7 +6227,7 @@ dependencies = [ [[package]] name = "base-sidecrush" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-provider", "alloy-rpc-types-eth 2.4.1", @@ -6240,7 +6240,7 @@ dependencies = [ [[package]] name = "base-sidecrush-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-sidecrush", "cadence", @@ -6252,7 +6252,7 @@ dependencies = [ [[package]] name = "base-snapshotter" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-provider", @@ -6279,7 +6279,7 @@ dependencies = [ [[package]] name = "base-snapshotter-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "aws-config", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "base-snark-e2e" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-provider", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "base-snark-e2e-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-snark-e2e", "tokio", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "base-system-tests" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6433,7 +6433,7 @@ dependencies = [ [[package]] name = "base-telemetry-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "base-cli-utils", @@ -6447,7 +6447,7 @@ dependencies = [ [[package]] name = "base-telemetry-service" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "anyhow", @@ -6477,7 +6477,7 @@ dependencies = [ [[package]] name = "base-test-utils" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-contract", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "base-trusted-proxy" -version = "1.5.0" +version = "1.5.1" dependencies = [ "http 1.4.2", "ipnet", @@ -6507,7 +6507,7 @@ dependencies = [ [[package]] name = "base-tx-forwarding" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6543,7 +6543,7 @@ dependencies = [ [[package]] name = "base-tx-manager" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6578,7 +6578,7 @@ dependencies = [ [[package]] name = "base-txpool-rpc" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "base-txpool-tracing" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "base-upgrade-signal" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-json-rpc 2.4.1", "alloy-primitives", @@ -6679,7 +6679,7 @@ dependencies = [ [[package]] name = "base-witness-diff" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-primitives", "alloy-provider", @@ -6705,7 +6705,7 @@ checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" [[package]] name = "base-zk-benchmarks" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -6723,7 +6723,7 @@ dependencies = [ [[package]] name = "base-zk-benchmarks-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-cli-utils", "base-load-tests", @@ -6736,7 +6736,7 @@ dependencies = [ [[package]] name = "base-zk-fork-dispute" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-node-bindings", "alloy-primitives", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "base-zk-fork-dispute-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "base-cli-utils", "base-zk-fork-dispute", @@ -6831,7 +6831,7 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "basectl" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "basectl-cli", @@ -6843,7 +6843,7 @@ dependencies = [ [[package]] name = "basectl-cli" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-contract", @@ -23986,7 +23986,7 @@ dependencies = [ [[package]] name = "websocket-proxy" -version = "1.5.0" +version = "1.5.1" dependencies = [ "axum 0.8.9", "backoff", @@ -24008,7 +24008,7 @@ dependencies = [ [[package]] name = "websocket-proxy-bin" -version = "1.5.0" +version = "1.5.1" dependencies = [ "axum 0.8.9", "base-cli-utils", diff --git a/Cargo.toml b/Cargo.toml index a1d4b45ff4..828272abb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "1.5.0" +version = "1.5.1" edition = "2024" rust-version = "1.96" license = "MIT" From 2694f4aefe7d5e935e558f2d09c37a79e59329e6 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 15 Oct 2026 23:24:50 +0200 Subject: [PATCH 02/18] fix(batcher): backport batcher fixes to v1.5.1 (#5876) * fix(batcher): time out every RPC call after --network-timeout (#5630) * fix(batcher): time out the L2 reads of a sequencer after 10 seconds * docs(batcher): keep the sequencer timeouts out of the CLI README * fix(batcher): time out every RPC call after --network-timeout * refactor(batcher): name the RPC client builder for what it builds * fix(batcher): log the L1 WS origin and document the network timeout * refactor(batcher): expose the tx manager settings as batcher config fields (cherry picked from commit f348cb60281df94df602be3f4023cf35c868f49f) * fix(batcher): keep the buffer when a node behind on L1 reports a lower safe head (#5660) * fix(batcher): keep the buffer when a node behind on L1 reports a lower safe head * docs(batcher): state the ordering a derivation status guarantees * fix(batcher): wait for a starting rollup node at startup instead of exiting * refactor(batcher): name the empty derivation status check after what it checks * docs(batcher): name both causes of a lower safe head past the last L1 block (cherry picked from commit 85af58bb1fbc6da76e537104c05cee9780ecdccb) * fix(batcher): give each blob count and low blob fill its own histogram bucket (#5773) (cherry picked from commit e9edf705dabebe79df23b9e17577e8fc76d22046) * fix(batcher): compare shadow parity only up to the validator safe head (#5832) (cherry picked from commit 68cb0ecb656ab0e864fa53ef332c714c26af146d) * fix(batcher): measure the DA backlog in estimated compressed bytes (#5833) (cherry picked from commit 2cebe4b16747202d5c1d03e082cc08ce8fe09fa9) * feat(batcher): expose the throttle DA limits as flags with calibrated defaults (#5835) * feat(batcher): expose the throttle DA limits as flags * feat(batcher): calibrate the throttle defaults for estimated DA bytes (cherry picked from commit 6e64f37a2dc910d176a19c3754903035a7f7bb16) * fix(batcher): let the driver drain when a background task exits (#5855) (cherry picked from commit 4fe6ef422a02f27c624bb4ba0a4de3bee0d10769) --- Cargo.lock | 3 + actions/harness/src/batcher/actor.rs | 8 +- actions/harness/tests/batcher/recovery.rs | 66 ++++- crates/batcher/cli/README.md | 22 +- crates/batcher/cli/src/cli.rs | 105 +++++-- crates/batcher/cli/src/metrics.rs | 21 +- crates/batcher/core/README.md | 11 +- crates/batcher/core/src/derivation_status.rs | 17 +- crates/batcher/core/src/driver.rs | 81 ++++-- crates/batcher/core/src/test_utils/builder.rs | 21 +- crates/batcher/core/src/throttle.rs | 60 ++-- crates/batcher/core/tests/heads.rs | 133 +++++++-- crates/batcher/core/tests/reorg.rs | 19 +- crates/batcher/core/tests/stop_start.rs | 4 +- crates/batcher/encoder/Cargo.toml | 1 + crates/batcher/encoder/src/buffered_block.rs | 97 +++++++ crates/batcher/encoder/src/encoder.rs | 38 +-- crates/batcher/encoder/src/lib.rs | 3 + crates/batcher/encoder/src/metrics.rs | 3 +- crates/batcher/encoder/src/pipeline.rs | 4 +- .../batcher/encoder/tests/encoder/backlog.rs | 17 +- crates/batcher/service/Cargo.toml | 6 +- crates/batcher/service/src/config.rs | 73 ++++- .../service/src/derivation_status_poller.rs | 21 +- crates/batcher/service/src/l2_block_parity.rs | 83 ++++-- crates/batcher/service/src/lib.rs | 3 + crates/batcher/service/src/rollup_node.rs | 40 +-- .../batcher/service/src/rpc_client_builder.rs | 132 +++++++++ crates/batcher/service/src/sequencers.rs | 52 ++-- crates/batcher/service/src/service.rs | 265 +++++++++++------- crates/batcher/service/src/test_utils.rs | 12 +- crates/batcher/service/src/throttle.rs | 49 ++-- etc/docs-index.toml | 4 +- 33 files changed, 1089 insertions(+), 385 deletions(-) create mode 100644 crates/batcher/encoder/src/buffered_block.rs create mode 100644 crates/batcher/service/src/rpc_client_builder.rs diff --git a/Cargo.lock b/Cargo.lock index 97abbb8176..8d5029ec6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3105,6 +3105,7 @@ dependencies = [ "alloy-rlp", "base-blobs", "base-common-consensus", + "base-common-flz", "base-common-genesis", "base-comp", "base-consensus-derive", @@ -3127,6 +3128,7 @@ dependencies = [ "alloy-node-bindings", "alloy-primitives", "alloy-provider", + "alloy-rpc-client", "alloy-rpc-types-eth 2.4.1", "alloy-sol-types", "async-trait", @@ -3152,6 +3154,7 @@ dependencies = [ "jsonrpsee", "metrics", "mockall", + "reqwest 0.13.4", "rstest", "serde_json", "tokio", diff --git a/actions/harness/src/batcher/actor.rs b/actions/harness/src/batcher/actor.rs index bbd9233e1e..876a8511ff 100644 --- a/actions/harness/src/batcher/actor.rs +++ b/actions/harness/src/batcher/actor.rs @@ -196,9 +196,13 @@ impl Batcher { BatchDriverInputs { source, l1_head_source, - // The driver learns the L1 head from the blocks the tests mine. + // The driver learns the L1 head from the blocks the tests mine, and the L1 + // progress of derivation from the statuses they pass to `observe_derivation`. initial_l1_head: 0, - initial_safe_head, + initial_derivation_status: DerivationStatus { + safe_l2: initial_safe_head, + ..Default::default() + }, derivation_status_rx, admin_rx, }, diff --git a/actions/harness/tests/batcher/recovery.rs b/actions/harness/tests/batcher/recovery.rs index ae6f9aecc5..26250fcfce 100644 --- a/actions/harness/tests/batcher/recovery.rs +++ b/actions/harness/tests/batcher/recovery.rs @@ -1,7 +1,8 @@ -//! How the [`Batcher`] recovers on its own, with every fork through Cobalt active, from a channel +//! How the [`Batcher`], with every fork through Cobalt active, recovers on its own from a channel //! that timed out in derivation, a confirmed batch that derivation passed over, a safe head that -//! went back, and an unsafe chain the sequencer replaced. No test builds a second batcher, and each -//! ends with derivation reading what the batcher resent by itself. +//! went back and an unsafe chain the sequencer replaced, and how it leaves a node behind on L1 +//! alone. No test builds a second batcher, and each recovery ends with derivation reading what the +//! batcher resent by itself. use base_action_harness::{ ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, @@ -135,8 +136,9 @@ async fn batcher_resends_a_confirmed_batch_that_derivation_passed_over() { ); } -/// When derivation loses derived blocks to an L1 reorg, its safe head goes back. The batcher, -/// told so, resends every block above the new safe head, and derivation reads them again. +/// When derivation loses derived blocks to an L1 reorg, its safe head goes back. Once it has +/// read the new fork past the L1 block the blocks were reported safe at, the batcher resends +/// every block above the new safe head, and derivation reads them again. #[tokio::test] async fn batcher_resends_the_blocks_above_a_safe_head_that_went_back() { let batcher_cfg = batcher_config(); @@ -155,16 +157,30 @@ async fn batcher_resends_the_blocks_above_a_safe_head_that_went_back() { chain.push(h.l1.tip().clone()); node.initialize().await; assert_eq!(node.run_until_idle().await, 3, "the three blocks derive"); - batcher.observe_derivation(node.derivation_status()).await; + let derived = node.derivation_status(); + batcher.observe_derivation(derived).await; // An L1 reorg replaces L1 block 1 with an empty block. Reset the node to genesis, as its - // L1 reorg handling would, and tell the batcher the safe head is back to 0. + // L1 reorg handling would. Until it reads the new fork past L1 block 1, it reports safe + // head 0 as a node behind on L1 would, and the batcher resends nothing. h.l1.reorg_to(0).expect("reorg to genesis"); chain.truncate_to(0); h.mine_and_push(&chain); batcher.observe_l1_block(h.l1.tip()).await; node.act_reset(h.l2_genesis()).await; - batcher.observe_derivation(node.derivation_status()).await; + let reset = node.derivation_status(); + assert!(reset.current_l1.number <= derived.current_l1.number, "the reset node is behind on L1"); + batcher.observe_derivation(reset).await; + h.mine_and_push(&chain); + batcher.observe_l1_block(h.l1.tip()).await; + assert_eq!(batcher.pending_count(), 0, "nothing is resent while the node is behind on L1"); + + // The node reads the new fork past L1 block 1 and its safe head is still 0, so the batcher + // resends the three blocks. + assert_eq!(node.run_until_idle().await, 0, "the batches are gone from L1"); + let passed = node.derivation_status(); + assert!(passed.current_l1.number > derived.current_l1.number, "derivation passed L1 block 1"); + batcher.observe_derivation(passed).await; // The next L1 block closes the fresh channel, the one after carries it. h.mine_and_push(&chain); @@ -180,6 +196,40 @@ async fn batcher_resends_the_blocks_above_a_safe_head_that_went_back() { ); } +/// A node behind on L1, as a new leader after a failover is, reports a lower safe head on the +/// same chain. The batcher resends nothing: that node derives the posted blocks on its own. +#[tokio::test] +async fn batcher_resends_nothing_for_a_node_behind_on_l1() { + let batcher_cfg = batcher_config(); + let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_config(&batcher_cfg)); + let mut sequencer = h.create_l2_sequencer(SharedL1Chain::from_blocks(h.l1.chain().to_vec())); + let blocks = sequencer.build_next_blocks_with_single_transactions(3).await; + let (mut node, chain) = h.create_test_rollup_node_from_sequencer( + &mut sequencer, + SharedL1Chain::from_blocks(h.l1.chain().to_vec()), + ); + + // The three blocks derive from L1 block 1, and the batcher hears the safe head is 3. The + // status of the node before it read L1 block 1 is the status a node behind on L1 reports. + let batcher = Batcher::new(ActionL2Source::from_blocks(blocks), &h.rollup_config, batcher_cfg); + batcher.advance(&mut h.l1).await; + chain.push(h.l1.tip().clone()); + node.initialize().await; + let behind = node.derivation_status(); + assert_eq!(node.run_until_idle().await, 3, "the three blocks derive"); + let derived = node.derivation_status(); + assert!(behind.current_l1.number < derived.current_l1.number, "the node read L1 block 1"); + batcher.observe_derivation(derived).await; + + // Told the safe head is 0 by a node that has not read L1 block 1, the batcher leaves its + // state alone: an L1 block later, when a resent channel would have closed, nothing is + // pending. + batcher.observe_derivation(behind).await; + h.mine_and_push(&chain); + batcher.observe_l1_block(h.l1.tip()).await; + assert_eq!(batcher.pending_count(), 0, "nothing is resent for a node behind on L1"); +} + /// When the sequencer replaces an unsafe block the batcher already took, as a new leader that /// never had it would, the batcher starts again from the safe head and sends the new chain, /// which derivation reads. diff --git a/crates/batcher/cli/README.md b/crates/batcher/cli/README.md index 8ce40a531d..a9933f1523 100644 --- a/crates/batcher/cli/README.md +++ b/crates/batcher/cli/README.md @@ -17,11 +17,11 @@ from the rollup config of the node whose derivation the batcher follows. `--sequencer-urls` / `BASE_BATCHER_SEQUENCER_URLS` takes the sequencer HTTP endpoints, comma-separated. The batcher submits the unsafe blocks of the leader, read with `eth_getBlockByNumber`. A single endpoint is the leader. Among several, -the leader is the first whose `admin_sequencerActive` answers `true`: an endpoint -that answers `false`, answers an error or does not answer within 2 seconds is -not active. The batcher waits for a leader at startup, up to -`--wait-node-sync-timeout`, then looks for it again every `--poll-interval`, and -keeps reading from the last one while none is. +the leader is the first whose `admin_sequencerActive` answers `true`, and one that +answers anything else, or does not answer within 2 seconds, or within +`--network-timeout` if that is shorter, is not active. The batcher waits for a +leader at startup, up to `--wait-node-sync-timeout`, then looks for it again every +`--poll-interval`, and keeps reading from the last one while none is. A canonical batcher also reads the rollup config of the leader (`optimism_rollupConfig`) and follows its derivation (`optimism_syncStatus`). @@ -47,10 +47,16 @@ selects blobs or calldata; `--max-channel-duration` and `--sub-safety-margin` control channel lifetime. `--throttle-start-threshold` sets the DA backlog from which the batcher throttles, `--throttle-full-threshold` the one from which it throttles the most, and `--throttle-strategy` how the intensity grows between -them: `off`, `step`, `linear` or `quadratic` (default). Unlike -`--no-throttle`, `off` keeps pushing the DA limits, at their highest. For +them: `off`, `step`, `linear` or `quadratic` (default). The DA limits pushed to +the sequencers go from `--throttle-block-size-upper-limit` and +`--throttle-tx-size-upper-limit` while not throttling down to +`--throttle-block-size-lower-limit` and `--throttle-tx-size-lower-limit` at +full intensity. Unlike `--no-throttle`, `off` keeps pushing the DA limits, at +their highest. For calldata configurations, `--no-force-blobs-when-throttling` disables the -throttle-driven blob override. The corresponding environment variables use the +throttle-driven blob override. `--network-timeout` bounds the RPC calls to L1, +the sequencers and the parity validator, so an endpoint that stops answering +never blocks the batcher. The corresponding environment variables use the `BASE_BATCHER_` prefix. ## Shadow mode diff --git a/crates/batcher/cli/src/cli.rs b/crates/batcher/cli/src/cli.rs index 9237af5474..84be2d8eb7 100644 --- a/crates/batcher/cli/src/cli.rs +++ b/crates/batcher/cli/src/cli.rs @@ -10,7 +10,7 @@ use base_batcher_core::{ThrottleConfig, ThrottleStrategy}; use base_batcher_service::{BatcherConfig, BatcherService, ShadowConfig}; use base_cli_utils::RuntimeManager; use base_runtime::TokioRuntime; -use base_tx_manager::{SignerConfig, TxManagerConfig}; +use base_tx_manager::SignerConfig; use clap::Parser; use url::Url; @@ -19,7 +19,7 @@ base_tx_manager::define_signer_cli!("BASE_BATCHER"); /// CLI arguments for the batcher. #[derive(Parser, Clone, Debug)] pub struct BatcherArgs { - /// L1 RPC endpoint. + /// L1 HTTP RPC endpoint. #[arg(long = "l1-rpc-url", visible_aliases = ["l1", "l1-eth-rpc"], env = "BASE_NODE_L1_ETH_RPC")] pub l1_rpc_url: Url, @@ -66,7 +66,7 @@ pub struct BatcherArgs { #[arg(long = "shadow.validator-rollup-rpc", env = "BASE_BATCHER_SHADOW_VALIDATOR_ROLLUP_RPC")] pub shadow_validator_rollup_rpc: Option, - /// Parity validator L2 RPC endpoint, whose derived block hashes are compared + /// Parity validator L2 HTTP RPC endpoint, whose derived block hashes are compared /// with the leader sequencer's. /// /// Required with `--shadow.enabled`. @@ -77,6 +77,14 @@ pub struct BatcherArgs { #[arg(long = "poll-interval", default_value = "1", env = "BASE_BATCHER_POLL_INTERVAL")] pub poll_interval_secs: u64, + /// Timeout in seconds of the RPC calls to L1, the sequencers and the parity validator. + /// + /// A call that times out is logged and retried like any other failed call, and the batcher + /// keeps running. At startup, the reads that set it up retry until + /// `--wait-node-sync-timeout`, then the batcher exits. + #[arg(long = "network-timeout", default_value = "10", env = "BASE_BATCHER_NETWORK_TIMEOUT")] + pub network_timeout_secs: u64, + /// Maximum L1 blocks a channel may stay open. #[arg( long = "max-channel-duration", @@ -157,24 +165,60 @@ pub struct BatcherArgs { )] pub publish_retry_delay: Duration, - /// DA backlog in bytes from which the batcher throttles, lowering the DA limits it pushes - /// to the `--sequencer-urls` endpoints. + /// DA backlog in estimated compressed bytes from which the batcher throttles, lowering the + /// DA limits it pushes to the `--sequencer-urls` endpoints. #[arg( long = "throttle-start-threshold", - default_value = "1000000", + default_value_t = ThrottleConfig::DEFAULT_START_THRESHOLD_BYTES, env = "BASE_BATCHER_THROTTLE_START_THRESHOLD" )] pub throttle_start_threshold: u64, - /// DA backlog in bytes from which the batcher throttles the most, with the lowest DA - /// limits. Must be above `--throttle-start-threshold`. + /// DA backlog in estimated compressed bytes from which the batcher throttles the most, + /// with the lowest DA limits. Must be above `--throttle-start-threshold`. #[arg( long = "throttle-full-threshold", - default_value = "2000000", + default_value_t = ThrottleConfig::DEFAULT_FULL_THRESHOLD_BYTES, env = "BASE_BATCHER_THROTTLE_FULL_THRESHOLD" )] pub throttle_full_threshold: u64, + /// Estimated compressed DA bytes allowed per block while not throttling. Throttling lowers + /// it towards `--throttle-block-size-lower-limit`. + #[arg( + long = "throttle-block-size-upper-limit", + default_value_t = ThrottleConfig::DEFAULT_BLOCK_SIZE_UPPER_LIMIT, + env = "BASE_BATCHER_THROTTLE_BLOCK_SIZE_UPPER_LIMIT" + )] + pub throttle_block_size_upper_limit: u64, + + /// Estimated compressed DA bytes allowed per block at full throttle intensity. Must be + /// above 0 and at most `--throttle-block-size-upper-limit`. + #[arg( + long = "throttle-block-size-lower-limit", + default_value_t = ThrottleConfig::DEFAULT_BLOCK_SIZE_LOWER_LIMIT, + env = "BASE_BATCHER_THROTTLE_BLOCK_SIZE_LOWER_LIMIT" + )] + pub throttle_block_size_lower_limit: u64, + + /// Estimated compressed DA bytes allowed per transaction while not throttling. Throttling + /// lowers it towards `--throttle-tx-size-lower-limit`. + #[arg( + long = "throttle-tx-size-upper-limit", + default_value_t = ThrottleConfig::DEFAULT_TX_SIZE_UPPER_LIMIT, + env = "BASE_BATCHER_THROTTLE_TX_SIZE_UPPER_LIMIT" + )] + pub throttle_tx_size_upper_limit: u64, + + /// Estimated compressed DA bytes allowed per transaction at full throttle intensity. Must + /// be above 0 and at most `--throttle-tx-size-upper-limit`. + #[arg( + long = "throttle-tx-size-lower-limit", + default_value_t = ThrottleConfig::DEFAULT_TX_SIZE_LOWER_LIMIT, + env = "BASE_BATCHER_THROTTLE_TX_SIZE_LOWER_LIMIT" + )] + pub throttle_tx_size_lower_limit: u64, + /// How the throttle intensity, from 0 (highest DA limits) to 1 (lowest), grows with /// the DA backlog between `--throttle-start-threshold` and `--throttle-full-threshold`. /// @@ -319,14 +363,6 @@ impl BatcherArgs { // Fail at startup, before constructing the service or accepting blocks. encoder_config.validate()?; - let tx_manager = TxManagerConfig { - num_confirmations: self.num_confirmations, - resubmission_timeout: Duration::from_secs(self.resubmission_timeout_secs), - publish_max_retries: self.publish_max_retries, - publish_retry_delay: self.publish_retry_delay, - ..TxManagerConfig::default() - }; - tx_manager.validate()?; Ok(BatcherConfig { l1_rpc_url: self.l1_rpc_url, l1_ws_url: self.l1_ws_url, @@ -335,9 +371,13 @@ impl BatcherArgs { metrics_enabled, shadow, poll_interval: Duration::from_secs(self.poll_interval_secs), + network_timeout: Duration::from_secs(self.network_timeout_secs), encoder_config, max_pending_transactions: self.max_pending_transactions, - tx_manager, + num_confirmations: self.num_confirmations, + resubmission_timeout: Duration::from_secs(self.resubmission_timeout_secs), + publish_max_retries: self.publish_max_retries, + publish_retry_delay: self.publish_retry_delay, throttle: if self.no_throttle { None } else { @@ -345,7 +385,10 @@ impl BatcherArgs { start_threshold_bytes: self.throttle_start_threshold, full_threshold_bytes: self.throttle_full_threshold, max_intensity: 1.0, - ..Default::default() + block_size_lower_limit: self.throttle_block_size_lower_limit, + block_size_upper_limit: self.throttle_block_size_upper_limit, + tx_size_lower_limit: self.throttle_tx_size_lower_limit, + tx_size_upper_limit: self.throttle_tx_size_upper_limit, }) }, throttle_strategy: self.throttle_strategy, @@ -496,6 +539,7 @@ mod tests { assert!(!config.stopped); assert!(!config.wait_node_sync); + assert_eq!(config.network_timeout, Duration::from_secs(10)); assert_eq!(config.throttle_strategy, ThrottleStrategy::Quadratic); assert_eq!(config.encoder_config.da_type, base_batcher_encoder::DaType::Blob); @@ -558,12 +602,22 @@ mod tests { "30", "--poll-interval", "2", + "--network-timeout", + "3", "--throttle-start-threshold", "500000", "--throttle-full-threshold", "800000", "--throttle-strategy", "linear", + "--throttle-block-size-upper-limit", + "900000", + "--throttle-block-size-lower-limit", + "40000", + "--throttle-tx-size-upper-limit", + "300000", + "--throttle-tx-size-lower-limit", + "3000", "--check-recent-txs-depth", "16", "--wait-node-sync-timeout", @@ -583,17 +637,22 @@ mod tests { assert_eq!(config.encoder_config.compressed_size_target, Some(1000)); assert_eq!(config.encoder_config.max_blobs_per_tx, 3); assert_eq!(config.encoder_config.brotli_level, base_batcher_encoder::BrotliLevel::Brotli5); - assert_eq!(config.tx_manager.publish_max_retries, 7); - assert_eq!(config.tx_manager.publish_retry_delay, Duration::from_secs(3)); + assert_eq!(config.publish_max_retries, 7); + assert_eq!(config.publish_retry_delay, Duration::from_secs(3)); assert_eq!(config.encoder_config.max_channel_duration, 10); assert_eq!(config.encoder_config.sub_safety_margin, 4); assert_eq!(config.max_pending_transactions, 4); - assert_eq!(config.tx_manager.num_confirmations, 3); - assert_eq!(config.tx_manager.resubmission_timeout, Duration::from_secs(30)); + assert_eq!(config.num_confirmations, 3); + assert_eq!(config.resubmission_timeout, Duration::from_secs(30)); assert_eq!(config.poll_interval, Duration::from_secs(2)); + assert_eq!(config.network_timeout, Duration::from_secs(3)); let throttle = config.throttle.expect("the throttle is on"); assert_eq!(throttle.start_threshold_bytes, 500_000); assert_eq!(throttle.full_threshold_bytes, 800_000); + assert_eq!(throttle.block_size_upper_limit, 900_000); + assert_eq!(throttle.block_size_lower_limit, 40_000); + assert_eq!(throttle.tx_size_upper_limit, 300_000); + assert_eq!(throttle.tx_size_lower_limit, 3_000); assert_eq!(config.throttle_strategy, ThrottleStrategy::Linear); assert_eq!(config.check_recent_txs_depth, 16); assert_eq!(config.wait_node_sync_timeout, Duration::from_secs(60)); diff --git a/crates/batcher/cli/src/metrics.rs b/crates/batcher/cli/src/metrics.rs index 1fc6d1228a..160c54fbc7 100644 --- a/crates/batcher/cli/src/metrics.rs +++ b/crates/batcher/cli/src/metrics.rs @@ -4,11 +4,14 @@ use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder}; const COMPRESSION_RATIO_BUCKETS: &[f64] = &[0.1, 0.2, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95]; -const BLOB_FILL_RATIO_BUCKETS: &[f64] = &[0.1, 0.25, 0.5, 0.75, 0.85, 0.9, 0.95, 0.99, 1.0]; +/// Low bounds resolve the partial blobs sent when a channel closes on its duration limit. +const BLOB_FILL_RATIO_BUCKETS: &[f64] = + &[0.02, 0.05, 0.1, 0.25, 0.5, 0.75, 0.85, 0.9, 0.95, 0.99, 1.0]; const CHANNEL_DURATION_BUCKETS: &[f64] = &[1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 40.0, 50.0]; const L2_BLOCKS_PER_CHANNEL_BUCKETS: &[f64] = &[1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1_000.0, 2_000.0]; -const BLOBS_PER_TX_BUCKETS: &[f64] = &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; +/// Bounds between the integer counts, so each blob count has a bucket of its own. +const BLOBS_PER_TX_BUCKETS: &[f64] = &[0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5]; const HISTOGRAM_BUCKETS: &[(&str, &[f64])] = &[ ("batcher.channel_compression_ratio", COMPRESSION_RATIO_BUCKETS), @@ -55,4 +58,18 @@ mod tests { assert!(!rendered.contains(&format!("{metric}{{quantile="))); } } + + /// A one-blob transaction falls in a bucket above zero, so quantiles derived from the + /// buckets do not report it as zero blobs. + #[test] + fn one_blob_has_a_bucket_of_its_own() { + let recorder = configure_prometheus(PrometheusBuilder::new()).unwrap().build_recorder(); + let handle = recorder.handle(); + + with_local_recorder(&recorder, || BatcherMetrics::blobs_per_tx().record(1.0)); + + let rendered = handle.render(); + assert!(rendered.contains("batcher_blobs_per_tx_bucket{le=\"0.5\"} 0")); + assert!(rendered.contains("batcher_blobs_per_tx_bucket{le=\"1.5\"} 1")); + } } diff --git a/crates/batcher/core/README.md b/crates/batcher/core/README.md index 032cb1baef..c97135fd16 100644 --- a/crates/batcher/core/README.md +++ b/crates/batcher/core/README.md @@ -5,13 +5,20 @@ Async orchestration core for the Base batcher. `BatchDriver` is the central type exported by this crate. It is generic over a `Runtime`, a `BatchPipeline` (frame encoding), an `UnsafeBlockSource` (L2 block delivery), an `L1HeadSource` (L1 chain head tracking) and a `TxManager` (L1 submission). Construction takes -`BatchDriverInputs`: the sources the driver listens to and the L1 head and safe L2 head it +`BatchDriverInputs`: the sources the driver listens to and the L1 head and derivation status it starts from. The initial L1 head seeds the pipeline, so channel duration is measured from the live L1 tip rather than from block 0. The driver runs a single `tokio::select!` task that reacts to unsafe L2 blocks, derivation-status updates, L1 heads, completed transaction receipts, admin commands, and cancellation. Each arm advances the pipeline or adjusts submission pressure without blocking the others. +A derivation status whose safe head is lower than the last one acted on is ignored while the +rollup node has not read L1 past the block the last safe head was reported at: such a node is +behind on L1, as a new leader or a restarted node is, and derives the same blocks again. Once +the node has read past that block with its safe head still lower, it did not derive the blocks +the last safe head covered, after an L1 reorg or a divergence between nodes, so the driver +resets the pipeline and posts the blocks above the lower safe head again. + `BatchDriverConfig` carries the L1 inbox address, in-flight transaction limit, shutdown drain timeout, DA-throttle submission policy, and whether block ingestion starts stopped. @@ -31,7 +38,7 @@ and `Failed`. The throttle subsystem controls how much DA data the block builders may include per block and per transaction based on the L1 DA backlog. `ThrottleController` takes a `ThrottleConfig` and a -`ThrottleStrategy` and produces `ThrottleParams` from a raw backlog byte count. +`ThrottleStrategy` and produces `ThrottleParams` from the backlog in estimated compressed DA bytes. `ThrottleStrategy::Off` never throttles, so `DaThrottle` publishes the upper limits. `ThrottleStrategy::Step` sets the intensity to `max_intensity` once the backlog reaches the start threshold. `ThrottleStrategy::Linear` grows intensity linearly from zero at the start threshold to diff --git a/crates/batcher/core/src/derivation_status.rs b/crates/batcher/core/src/derivation_status.rs index 8f9ca2ee10..a882adad8b 100644 --- a/crates/batcher/core/src/derivation_status.rs +++ b/crates/batcher/core/src/derivation_status.rs @@ -2,8 +2,12 @@ use base_protocol::BlockInfo; -/// A coherent snapshot of the derivation progress relevant to the batcher. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// The derivation progress of the rollup node the batcher follows. +/// +/// The safe head includes every block derived from the L1 blocks before `current_l1`: a node +/// moves its derivation past an L1 block only once its engine has made those blocks safe, and +/// reads `current_l1` before the safe head. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct DerivationStatus { /// The safe L2 head derivation has reached. pub safe_l2: BlockInfo, @@ -11,3 +15,12 @@ pub struct DerivationStatus { /// carried, has been read. pub current_l1: BlockInfo, } + +impl DerivationStatus { + /// Whether the status lacks a safe L2 head or an L1 block. A node reports them empty until + /// its engine is bootstrapped and its derivation pipeline has an origin, as at startup or + /// during execution-layer sync. + pub fn lacks_safe_head_or_l1_block(&self) -> bool { + self.safe_l2 == BlockInfo::default() || self.current_l1 == BlockInfo::default() + } +} diff --git a/crates/batcher/core/src/driver.rs b/crates/batcher/core/src/driver.rs index 0025081c06..7d314e59d0 100644 --- a/crates/batcher/core/src/driver.rs +++ b/crates/batcher/core/src/driver.rs @@ -5,7 +5,6 @@ use std::time::Duration; use base_batcher_encoder::{BatchPipeline, BatcherMetrics, DerivationReconciliation, StepResult}; use base_batcher_source::{L1HeadSource, L2BlockEvent, UnsafeBlockSource}; use base_common_consensus::BaseBlock; -use base_protocol::BlockInfo; use base_runtime::Runtime; use base_tx_manager::TxManager; use tokio::sync::mpsc; @@ -19,7 +18,8 @@ use crate::{ /// Encoding steps per CPU phase. const STEP_BUDGET: usize = 128; -/// The sources a [`BatchDriver`] listens to, and the L1 head and safe L2 head it starts from. +/// The sources a [`BatchDriver`] listens to, and the L1 head and derivation status it starts +/// from. #[derive(Debug)] pub struct BatchDriverInputs { /// Source of unsafe L2 blocks and reorg signals. @@ -28,8 +28,8 @@ pub struct BatchDriverInputs { pub l1_head_source: L, /// Live L1 head at startup. pub initial_l1_head: u64, - /// Safe L2 head at startup. - pub initial_safe_head: BlockInfo, + /// Derivation status at startup. + pub initial_derivation_status: DerivationStatus, /// Ordered derivation-status updates. pub derivation_status_rx: mpsc::Receiver, /// Admin commands; see [`AdminHandle::channel`](crate::AdminHandle::channel). @@ -65,9 +65,10 @@ where throttle: DaThrottle, /// L1 head source for chain head advancement. l1_head_source: L, - /// Last trusted L2 safe head. - safe_head: BlockInfo, - /// Ordered derivation-progress snapshots. + /// The last derivation status acted on. Blocks at or below its safe head are dropped as + /// derived and catchup restarts above it; a lower safe head is judged against its L1 block. + derivation: DerivationStatus, + /// Ordered derivation statuses. derivation_status_rx: mpsc::Receiver, /// Maximum wall-clock time to wait for in-flight submissions to settle /// when draining on cancellation. @@ -115,7 +116,7 @@ where ), throttle, l1_head_source: inputs.l1_head_source, - safe_head: inputs.initial_safe_head, + derivation: inputs.initial_derivation_status, derivation_status_rx: inputs.derivation_status_rx, drain_timeout: config.drain_timeout, stopped: config.stopped, @@ -269,38 +270,53 @@ where self.pipeline.reset(); } - /// Reset volatile state and restart delivery above the latest safe head. + /// Reset volatile state and restart delivery above the safe head. fn reset_to_safe_head(&mut self, reason: &'static str) { self.reset_pipeline(reason); - self.source.reset_catchup(self.safe_head); + self.source.reset_catchup(self.derivation.safe_l2); } - /// Reconcile buffered state with an ordered derivation-progress snapshot. + /// Reconcile buffered state with a derivation status. + /// + /// A safe head lower than the last one acted on is ignored while the node has not read L1 + /// past the block the last safe head was reported at: such a node is behind on L1, as a new + /// leader or a restarted node is, and derives the same blocks again. Once it has read past + /// that block with its safe head still lower, it did not derive the blocks the last safe + /// head covered, after an L1 reorg or a divergence between nodes, so the driver resets the + /// pipeline and posts the blocks above the lower safe head again. fn on_derivation_status(&mut self, status: DerivationStatus) { - let head = status.safe_l2; - let previous = self.safe_head; - self.safe_head = head; + let last = self.derivation; + let went_back = status.safe_l2.number < last.safe_l2.number; + if went_back && status.current_l1.number <= last.current_l1.number { + debug!( + safe_l2 = %status.safe_l2.number, + current_l1 = %status.current_l1.number, + last_safe_l2 = %last.safe_l2.number, + last_current_l1 = %last.current_l1.number, + "rollup node behind on L1, ignoring its lower safe head" + ); + return; + } + self.derivation = status; - if head.number < previous.number - || (head.number == previous.number && head.hash != previous.hash) - { + if went_back { warn!( - previous_safe_l2 = %previous.number, - previous_safe_hash = %previous.hash, - safe_l2 = %head.number, - safe_hash = %head.hash, - "safe L2 head changed chain, resetting pipeline" + safe_l2 = %status.safe_l2.number, + current_l1 = %status.current_l1.number, + last_safe_l2 = %last.safe_l2.number, + last_current_l1 = %last.current_l1.number, + "safe L2 head went back with derivation past the last one's L1 block, resetting pipeline" ); self.reset_to_safe_head(BatcherMetrics::RESET_SAFE_HEAD_REORG); return; } - match self.pipeline.reconcile_derivation(head, status.current_l1.number) { + match self.pipeline.reconcile_derivation(status.safe_l2, status.current_l1.number) { DerivationReconciliation::Consistent => {} DerivationReconciliation::SafeHeadMismatch => { warn!( - safe_l2 = %head.number, - safe_hash = %head.hash, + safe_l2 = %status.safe_l2.number, + safe_hash = %status.safe_l2.hash, "safe L2 head does not match buffered chain, resetting pipeline" ); self.reset_to_safe_head(BatcherMetrics::RESET_SAFE_HEAD_MISMATCH); @@ -308,7 +324,7 @@ where DerivationReconciliation::StalledChannel => { warn!( current_l1 = %status.current_l1.number, - safe_l2 = %head.number, + safe_l2 = %status.safe_l2.number, "rollup node passed a fully confirmed channel without deriving it, resetting pipeline" ); self.reset_to_safe_head(BatcherMetrics::RESET_STALLED_CHANNEL); @@ -319,11 +335,11 @@ where /// Ingest a new L2 block into the pipeline. /// /// If the pipeline signals a reorg via `add_block` (parent-hash mismatch), - /// resets the pipeline and restarts sequential catchup from `safe_head + 1`. + /// resets the pipeline and restarts sequential catchup above the safe head. /// The triggering block will be re-delivered by the sequential poller. fn on_block(&mut self, block: Box) { let number = block.header.number; - if number <= self.safe_head.number { + if number <= self.derivation.safe_l2.number { return; } @@ -364,10 +380,10 @@ where return; } - self.source.reset_catchup(self.safe_head); + self.source.reset_catchup(self.derivation.safe_l2); info!( stopped = false, - safe_l2 = %self.safe_head.number, + safe_l2 = %self.derivation.safe_l2.number, "batcher started via admin, catching up from safe head" ); self.stopped = false; @@ -483,7 +499,10 @@ mod tests { DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) .l1_head_source(l1_head_source) - .safe_head(BlockStub::info(5)) + .derivation_status(DerivationStatus { + safe_l2: BlockStub::info(5), + ..Default::default() + }) .build(); let handle = ctx.spawn(driver.run()); diff --git a/crates/batcher/core/src/test_utils/builder.rs b/crates/batcher/core/src/test_utils/builder.rs index 5e5fdab10d..e8855e20cb 100644 --- a/crates/batcher/core/src/test_utils/builder.rs +++ b/crates/batcher/core/src/test_utils/builder.rs @@ -49,8 +49,9 @@ impl SubmissionStub { /// Builds a [`BatchDriver`] for tests. /// /// Unless told otherwise, the driver has a parked source, a parked L1 head source, a disabled -/// throttle and at most one in-flight transaction. It starts from L1 head 0 and from the L2 -/// genesis (block 0) as safe head, so it drops blocks numbered 0 as already safe. +/// throttle and at most one in-flight transaction. It starts from L1 head 0 and from the +/// default derivation status, with the L2 genesis (block 0) as safe head and derivation at L1 +/// block 0, so it drops blocks numbered 0 as already safe. /// /// [`build`](Self::build) also creates the derivation-status and admin channels and hands /// their sending sides back as [`DriverHandles`]. Keep them alive while the driver runs: @@ -66,7 +67,7 @@ pub struct DriverFixture { throttle: DaThrottle, max_pending: usize, initial_l1_head: u64, - safe_head: BlockInfo, + derivation_status: DerivationStatus, force_blobs_when_throttling: bool, } @@ -94,7 +95,7 @@ impl DriverFixture { throttle: DaThrottle::new(ThrottleController::disabled()), max_pending: 1, initial_l1_head: 0, - safe_head: BlockInfo::default(), + derivation_status: DerivationStatus::default(), force_blobs_when_throttling: true, } } @@ -119,7 +120,7 @@ where throttle: self.throttle, max_pending: self.max_pending, initial_l1_head: self.initial_l1_head, - safe_head: self.safe_head, + derivation_status: self.derivation_status, force_blobs_when_throttling: self.force_blobs_when_throttling, } } @@ -138,7 +139,7 @@ where throttle: self.throttle, max_pending: self.max_pending, initial_l1_head: self.initial_l1_head, - safe_head: self.safe_head, + derivation_status: self.derivation_status, force_blobs_when_throttling: self.force_blobs_when_throttling, } } @@ -161,9 +162,9 @@ where self } - /// Set the safe L2 head the driver starts from. - pub const fn safe_head(mut self, safe_head: BlockInfo) -> Self { - self.safe_head = safe_head; + /// Set the derivation status the driver starts from. + pub const fn derivation_status(mut self, derivation_status: DerivationStatus) -> Self { + self.derivation_status = derivation_status; self } @@ -193,7 +194,7 @@ where source: self.source, l1_head_source: self.l1_head_source, initial_l1_head: self.initial_l1_head, - initial_safe_head: self.safe_head, + initial_derivation_status: self.derivation_status, derivation_status_rx, admin_rx, }, diff --git a/crates/batcher/core/src/throttle.rs b/crates/batcher/core/src/throttle.rs index 1a165839f1..c67325134e 100644 --- a/crates/batcher/core/src/throttle.rs +++ b/crates/batcher/core/src/throttle.rs @@ -9,44 +9,55 @@ use tracing::info; /// limit of 0 as no limit at all. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ThrottleConfig { - /// Backlog in bytes from which throttling starts. - /// Default: 1,000,000 bytes (1 MB). + /// Backlog in estimated DA bytes from which throttling starts. pub start_threshold_bytes: u64, - /// Backlog in bytes from which the intensity is `max_intensity`. - /// Default: 2,000,000 bytes (2 MB). + /// Backlog in estimated DA bytes from which the intensity is `max_intensity`. pub full_threshold_bytes: u64, /// Maximum throttle intensity (0.0 to 1.0). - /// Default: 1.0 (full throttle). pub max_intensity: f64, /// Maximum block DA bytes allowed at full throttle intensity. - /// Default: 2,000 bytes. pub block_size_lower_limit: u64, /// Maximum block DA bytes allowed when not throttling. - /// Default: 130,000 bytes. pub block_size_upper_limit: u64, /// Maximum transaction DA bytes allowed at full throttle intensity. - /// Default: 150 bytes. pub tx_size_lower_limit: u64, /// Maximum transaction DA bytes allowed when not throttling. - /// Default: 20,000 bytes. pub tx_size_upper_limit: u64, } impl Default for ThrottleConfig { fn default() -> Self { Self { - start_threshold_bytes: 1_000_000, - full_threshold_bytes: 2_000_000, + start_threshold_bytes: Self::DEFAULT_START_THRESHOLD_BYTES, + full_threshold_bytes: Self::DEFAULT_FULL_THRESHOLD_BYTES, max_intensity: 1.0, - block_size_lower_limit: 2_000, - block_size_upper_limit: 130_000, - tx_size_lower_limit: 150, - tx_size_upper_limit: 20_000, + block_size_lower_limit: Self::DEFAULT_BLOCK_SIZE_LOWER_LIMIT, + block_size_upper_limit: Self::DEFAULT_BLOCK_SIZE_UPPER_LIMIT, + tx_size_lower_limit: Self::DEFAULT_TX_SIZE_LOWER_LIMIT, + tx_size_upper_limit: Self::DEFAULT_TX_SIZE_UPPER_LIMIT, } } } impl ThrottleConfig { + /// Default backlog from which throttling starts, in estimated DA bytes. + pub const DEFAULT_START_THRESHOLD_BYTES: u64 = 120_000_000; + + /// Default backlog from which the intensity is maximal, in estimated DA bytes. + pub const DEFAULT_FULL_THRESHOLD_BYTES: u64 = 360_000_000; + + /// Default block DA limit while not throttling, in estimated DA bytes. + pub const DEFAULT_BLOCK_SIZE_UPPER_LIMIT: u64 = 1_900_000; + + /// Default block DA limit at full intensity, in estimated DA bytes. + pub const DEFAULT_BLOCK_SIZE_LOWER_LIMIT: u64 = 42_000; + + /// Default transaction DA limit while not throttling, in estimated DA bytes. + pub const DEFAULT_TX_SIZE_UPPER_LIMIT: u64 = 390_000; + + /// Default transaction DA limit at full intensity, in estimated DA bytes. + pub const DEFAULT_TX_SIZE_LOWER_LIMIT: u64 = 3_500; + /// Checks that the full threshold is above the start threshold, and that every limit the /// throttle can produce stays within its `[lower, upper]` range and above zero. pub fn validate(&self) -> Result<(), ThrottleConfigError> { @@ -396,7 +407,10 @@ mod tests { start_threshold_bytes: 1000, full_threshold_bytes: 3000, max_intensity: 0.8, - ..Default::default() + block_size_lower_limit: 2_000, + block_size_upper_limit: 130_000, + tx_size_lower_limit: 150, + tx_size_upper_limit: 20_000, }; let controller = ThrottleController::new(config, strategy); @@ -414,11 +428,11 @@ mod tests { #[rstest] #[case::default(ThrottleConfig::default(), Ok(()))] #[case::full_threshold_equal_to_start( - ThrottleConfig { full_threshold_bytes: 1_000_000, ..Default::default() }, - Err("full_threshold_bytes (1000000) must be above start_threshold_bytes (1000000)") + ThrottleConfig { full_threshold_bytes: 120_000_000, ..Default::default() }, + Err("full_threshold_bytes (120000000) must be above start_threshold_bytes (120000000)") )] #[case::equal_limits( - ThrottleConfig { block_size_lower_limit: 130_000, ..Default::default() }, + ThrottleConfig { block_size_lower_limit: 1_900_000, ..Default::default() }, Ok(()) )] #[case::intensity_above_one( @@ -442,12 +456,12 @@ mod tests { Err("tx_size_lower_limit must be greater than zero") )] #[case::block_lower_above_upper( - ThrottleConfig { block_size_lower_limit: 130_001, ..Default::default() }, - Err("block_size_lower_limit (130001) must not exceed block_size_upper_limit (130000)") + ThrottleConfig { block_size_lower_limit: 1_900_001, ..Default::default() }, + Err("block_size_lower_limit (1900001) must not exceed block_size_upper_limit (1900000)") )] #[case::tx_lower_above_upper( - ThrottleConfig { tx_size_lower_limit: 20_001, ..Default::default() }, - Err("tx_size_lower_limit (20001) must not exceed tx_size_upper_limit (20000)") + ThrottleConfig { tx_size_lower_limit: 390_001, ..Default::default() }, + Err("tx_size_lower_limit (390001) must not exceed tx_size_upper_limit (390000)") )] fn validate_rejects_each_broken_rule( #[case] config: ThrottleConfig, diff --git a/crates/batcher/core/tests/heads.rs b/crates/batcher/core/tests/heads.rs index 9e8f307f5d..419c48cdbb 100644 --- a/crates/batcher/core/tests/heads.rs +++ b/crates/batcher/core/tests/heads.rs @@ -10,17 +10,115 @@ use base_batcher_core::{ }, }; use base_batcher_encoder::DerivationReconciliation; +use base_batcher_source::L2BlockEvent; use base_protocol::BlockInfo; use base_runtime::{ Cancellation, Clock, Spawner, deterministic::{Config, Runner}, }; -/// A lower safe head, a different block at the same height and a safe head missing from the -/// buffered chain each reset the pipeline and restart the source from the new safe head. The -/// driver catches the first two itself and sends only the third through reconciliation. +/// A status reporting `safe_l2` with derivation at L1 block `current_l1`. +fn status(safe_l2: BlockInfo, current_l1: u64) -> DerivationStatus { + DerivationStatus { safe_l2, current_l1: BlockStub::info(current_l1) } +} + +/// A lower safe head from a node that has not read L1 past the block the last safe head was +/// reported at is the status of a node behind on L1. The driver neither reconciles it nor +/// resets, and the reset an L2 reorg triggers meanwhile restarts the source from the last safe +/// head, not the lower one. +#[test] +fn test_lower_safe_head_from_a_node_behind_on_l1_is_ignored() { + Runner::start(Config::seeded(0), |ctx| async move { + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + let (source, catchup_heads) = TrackingSource::new(); + + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source.with_events([L2BlockEvent::Reorg])) + .derivation_status(status(BlockStub::info(10), 5)) + .build(); + let status_tx = handles.derivation_status_tx; + + // The first status, at the L1 block of the last one, is served before the source reorg + // and the second, below it, after. The catchup head then shows neither moved the safe + // head. + status_tx.send(status(BlockStub::info(5), 5)).await.unwrap(); + let handle = ctx.spawn(driver.run()); + status_tx.send(status(BlockStub::info(5), 3)).await.unwrap(); + ctx.sleep(Duration::from_millis(50)).await; + ctx.cancel(); + + assert!(handle.await.unwrap().is_ok()); + assert_eq!(recorded.lock().unwrap().calls, [PipelineCall::Reset, PipelineCall::Flush]); + assert_eq!(*catchup_heads.lock().unwrap(), [BlockStub::info(10)]); + }); +} + +/// A lower safe head is judged against the last status acted on, so the L1 block a node must +/// have read past moves with every reconciled status. +#[test] +fn test_lower_safe_head_is_judged_against_the_last_status_acted_on() { + Runner::start(Config::seeded(0), |ctx| async move { + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + let (source, catchup_heads) = TrackingSource::new(); + + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) + .derivation_status(status(BlockStub::info(10), 5)) + .build(); + let handle = ctx.spawn(driver.run()); + let status_tx = handles.derivation_status_tx; + + // Past the initial L1 block, but not past the one of the reconciled status. + status_tx.send(status(BlockStub::info(12), 8)).await.unwrap(); + status_tx.send(status(BlockStub::info(11), 7)).await.unwrap(); + ctx.sleep(Duration::from_millis(50)).await; + ctx.cancel(); + + assert!(handle.await.unwrap().is_ok()); + assert_eq!( + recorded.lock().unwrap().calls, + [PipelineCall::ReconcileDerivation { safe_l2: 12, current_l1: 8 }, PipelineCall::Flush] + ); + assert!(catchup_heads.lock().unwrap().is_empty()); + }); +} + +/// A lower safe head from a node that has read L1 past the block the last safe head was +/// reported at did not derive the blocks the last safe head covered. The driver resets the +/// pipeline and restarts the source from the lower safe head, without reconciling. +#[test] +fn test_lower_safe_head_from_a_node_ahead_on_l1_resets_pipeline_and_source() { + Runner::start(Config::seeded(0), |ctx| async move { + let pipeline = TrackingPipeline::new(); + let recorded = pipeline.recorded(); + let (source, catchup_heads) = TrackingSource::new(); + + let (driver, handles) = + DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) + .source(source) + .derivation_status(status(BlockStub::info(10), 5)) + .build(); + let handle = ctx.spawn(driver.run()); + + handles.derivation_status_tx.send(status(BlockStub::info(5), 6)).await.unwrap(); + ctx.sleep(Duration::from_millis(50)).await; + ctx.cancel(); + + assert!(handle.await.unwrap().is_ok()); + assert_eq!(recorded.lock().unwrap().calls, [PipelineCall::Reset, PipelineCall::Flush]); + assert_eq!(*catchup_heads.lock().unwrap(), [BlockStub::info(5)]); + }); +} + +/// A safe head at the same height as the last one with another hash goes through +/// reconciliation: the pipeline finds it off the buffered chain, and the driver resets the +/// pipeline and restarts the source from it. #[test] -fn test_safe_head_conflicts_reset_pipeline_and_source() { +fn test_safe_head_off_the_buffered_chain_resets_pipeline_and_source() { Runner::start(Config::seeded(0), |ctx| async move { let pipeline = TrackingPipeline::new().with_reconciliation(DerivationReconciliation::SafeHeadMismatch); @@ -30,20 +128,13 @@ fn test_safe_head_conflicts_reset_pipeline_and_source() { let (driver, handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) - .safe_head(BlockStub::info(10)) + .derivation_status(status(BlockStub::info(10), 5)) .build(); let handle = ctx.spawn(driver.run()); - let status_tx = handles.derivation_status_tx; - let regressed = BlockStub::info(5); let replacement = - BlockInfo { hash: B256::repeat_byte(0xff), number: 5, ..Default::default() }; - for safe_l2 in [regressed, replacement, BlockStub::info(10)] { - status_tx - .send(DerivationStatus { safe_l2, current_l1: BlockStub::info(1) }) - .await - .unwrap(); - } + BlockInfo { hash: B256::repeat_byte(0xff), number: 10, ..Default::default() }; + handles.derivation_status_tx.send(status(replacement, 5)).await.unwrap(); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); @@ -51,14 +142,12 @@ fn test_safe_head_conflicts_reset_pipeline_and_source() { assert_eq!( recorded.lock().unwrap().calls, [ - PipelineCall::Reset, - PipelineCall::Reset, - PipelineCall::ReconcileDerivation { safe_l2: 10, current_l1: 1 }, + PipelineCall::ReconcileDerivation { safe_l2: 10, current_l1: 5 }, PipelineCall::Reset, PipelineCall::Flush, ] ); - assert_eq!(*catchup_heads.lock().unwrap(), [regressed, replacement, BlockStub::info(10)]); + assert_eq!(*catchup_heads.lock().unwrap(), [replacement]); }); } @@ -77,15 +166,11 @@ fn test_stalled_channel_resets_pipeline_and_source() { let (driver, handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) - .safe_head(safe_l2) + .derivation_status(status(safe_l2, 1)) .build(); let handle = ctx.spawn(driver.run()); - let status_tx = handles.derivation_status_tx; - status_tx - .send(DerivationStatus { safe_l2, current_l1: BlockStub::info(50) }) - .await - .unwrap(); + handles.derivation_status_tx.send(status(safe_l2, 50)).await.unwrap(); ctx.sleep(Duration::from_millis(50)).await; ctx.cancel(); diff --git a/crates/batcher/core/tests/reorg.rs b/crates/batcher/core/tests/reorg.rs index defc8bc8b9..4808a050d0 100644 --- a/crates/batcher/core/tests/reorg.rs +++ b/crates/batcher/core/tests/reorg.rs @@ -2,9 +2,12 @@ use std::time::Duration; -use base_batcher_core::test_utils::{ - BlockStub, DriverFixture, PipelineCall, ScriptedTxManager, SubmissionStub, TrackingPipeline, - TrackingSource, +use base_batcher_core::{ + DerivationStatus, + test_utils::{ + BlockStub, DriverFixture, PipelineCall, ScriptedTxManager, SubmissionStub, + TrackingPipeline, TrackingSource, + }, }; use base_batcher_source::{L2BlockEvent, test_utils::ChannelBlockSource}; use base_runtime::{ @@ -26,7 +29,10 @@ fn test_add_block_reorg_resets_pipeline_and_source() { let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source) - .safe_head(BlockStub::info(10)) + .derivation_status(DerivationStatus { + safe_l2: BlockStub::info(10), + ..Default::default() + }) .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(10)).await; @@ -53,7 +59,10 @@ fn test_l2_reorg_event_resets_pipeline_and_source() { let (driver, _handles) = DriverFixture::new(ctx.clone(), pipeline, ScriptedTxManager::confirming_at(1)) .source(source.with_events([L2BlockEvent::Reorg])) - .safe_head(BlockStub::info(10)) + .derivation_status(DerivationStatus { + safe_l2: BlockStub::info(10), + ..Default::default() + }) .build(); let handle = ctx.spawn(driver.run()); ctx.sleep(Duration::from_millis(10)).await; diff --git a/crates/batcher/core/tests/stop_start.rs b/crates/batcher/core/tests/stop_start.rs index 59951ba3ab..a0e760ded3 100644 --- a/crates/batcher/core/tests/stop_start.rs +++ b/crates/batcher/core/tests/stop_start.rs @@ -3,7 +3,7 @@ use std::time::Duration; use base_batcher_core::{ - AdminError, + AdminError, DerivationStatus, test_utils::{ BlockStub, DriverFixture, ScriptedTxManager, SubmissionStub, TrackingPipeline, TrackingSource, @@ -50,7 +50,7 @@ fn test_start_triggers_catchup_from_safe_head() { ScriptedTxManager::confirming_at(1), ) .source(source) - .safe_head(safe_head) + .derivation_status(DerivationStatus { safe_l2: safe_head, ..Default::default() }) .build(); let handle = ctx.spawn(driver.run()); diff --git a/crates/batcher/encoder/Cargo.toml b/crates/batcher/encoder/Cargo.toml index e5c4c9ad58..d4f6d03175 100644 --- a/crates/batcher/encoder/Cargo.toml +++ b/crates/batcher/encoder/Cargo.toml @@ -21,6 +21,7 @@ thiserror = { workspace = true, features = ["std"] } alloy-eips = { workspace = true, features = ["std"] } base-protocol = { workspace = true, features = ["std"] } derive_more = { workspace = true, features = ["debug"] } +base-common-flz = { workspace = true, features = ["std"] } alloy-primitives = { workspace = true, features = ["std"] } rand = { workspace = true, features = ["std", "small_rng"] } base-common-consensus = { workspace = true, features = ["std"] } diff --git a/crates/batcher/encoder/src/buffered_block.rs b/crates/batcher/encoder/src/buffered_block.rs new file mode 100644 index 0000000000..1b64ae0020 --- /dev/null +++ b/crates/batcher/encoder/src/buffered_block.rs @@ -0,0 +1,97 @@ +//! An L2 block buffered by the encoder with its estimated DA size. + +use alloy_eips::eip2718::Encodable2718; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; +use base_common_flz::tx_estimated_size_fjord_bytes; + +/// An L2 block queued for encoding, with the DA bytes it is estimated to cost once compressed. +/// +/// The estimate is the Fjord `FastLZ` size of each non-deposit transaction plus a fixed batch +/// overhead, the same unit the block builders apply DA limits in. It is computed once on entry +/// because the throttle reads the backlog on every driver iteration. +#[derive(Debug, Clone)] +pub struct BufferedBlock { + block: BaseBlock, + estimated_da_bytes: u64, +} + +impl BufferedBlock { + /// Estimated DA bytes of the batch metadata that wraps a block's transactions. + pub const BATCH_OVERHEAD_BYTES: u64 = 70; + + /// Buffers `block` and estimates its DA size. + pub fn new(block: BaseBlock) -> Self { + let transactions: u64 = block + .body + .transactions + .iter() + .filter(|tx| !matches!(tx, BaseTxEnvelope::Deposit(_))) + .map(|tx| tx_estimated_size_fjord_bytes(&tx.encoded_2718())) + .sum(); + Self { block, estimated_da_bytes: Self::BATCH_OVERHEAD_BYTES + transactions } + } + + /// Returns the buffered block. + pub const fn block(&self) -> &BaseBlock { + &self.block + } + + /// Returns the estimated DA bytes of the block once compressed. + pub const fn estimated_da_bytes(&self) -> u64 { + self.estimated_da_bytes + } +} + +#[cfg(test)] +mod tests { + use alloy_consensus::{BlockBody, SignableTransaction, TxLegacy}; + use alloy_primitives::{Bytes, Sealed, Signature}; + use base_common_consensus::TxDeposit; + + use super::*; + + fn user_tx(input: Vec) -> BaseTxEnvelope { + BaseTxEnvelope::Legacy( + TxLegacy { input: Bytes::from(input), ..Default::default() } + .into_signed(Signature::test_signature()), + ) + } + + fn block(transactions: Vec) -> BaseBlock { + BaseBlock { + header: Default::default(), + body: BlockBody { transactions, ..Default::default() }, + } + } + + #[test] + fn estimate_is_the_fjord_size_of_user_transactions_plus_batch_overhead() { + let deposit = BaseTxEnvelope::Deposit(Sealed::new(TxDeposit { + input: Bytes::from(vec![0xab; 4_096]), + ..Default::default() + })); + let user = user_tx((0..=u8::MAX).cycle().take(4_096).collect()); + let user_estimate = tx_estimated_size_fjord_bytes(&user.encoded_2718()); + + let buffered = BufferedBlock::new(block(vec![deposit, user])); + + assert_eq!( + buffered.estimated_da_bytes(), + BufferedBlock::BATCH_OVERHEAD_BYTES + user_estimate + ); + } + + #[test] + fn compressible_transactions_count_their_compressed_size_not_their_raw_size() { + let transactions: Vec<_> = (0..20).map(|_| user_tx(vec![0; 100_000])).collect(); + let raw_bytes: u64 = transactions.iter().map(|tx| tx.encode_2718_len() as u64).sum(); + + let buffered = BufferedBlock::new(block(transactions)); + + assert!( + buffered.estimated_da_bytes() < raw_bytes / 50, + "estimated {} DA bytes for {raw_bytes} raw bytes", + buffered.estimated_da_bytes() + ); + } +} diff --git a/crates/batcher/encoder/src/encoder.rs b/crates/batcher/encoder/src/encoder.rs index cbbd1e004d..f5ede45a6a 100644 --- a/crates/batcher/encoder/src/encoder.rs +++ b/crates/batcher/encoder/src/encoder.rs @@ -2,17 +2,16 @@ use std::{collections::VecDeque, fmt, sync::Arc}; -use alloy_eips::eip2718::Encodable2718; use alloy_primitives::B256; -use base_common_consensus::{BaseBlock, BaseTxEnvelope}; +use base_common_consensus::BaseBlock; use base_common_genesis::RollupConfig; use base_protocol::{BlockInfo, ChannelId}; use rand::{RngCore, SeedableRng, rngs::SmallRng}; use tracing::{debug, warn}; use crate::{ - ArtifactId, BatchComposer, BatchPipeline, BatchSubmission, BatcherMetrics, Channel, - ChannelAddOutcome, ChannelCloseReason, DaEgress, DaType, DerivationReconciliation, + ArtifactId, BatchComposer, BatchPipeline, BatchSubmission, BatcherMetrics, BufferedBlock, + Channel, ChannelAddOutcome, ChannelCloseReason, DaEgress, DaType, DerivationReconciliation, EncoderConfig, EncoderConfigError, ReorgError, StepError, StepResult, SubmissionId, }; @@ -28,7 +27,7 @@ pub struct BatchEncoder { /// Current L1 head block number (for channel duration tracking). l1_head: u64, /// Buffered L2 blocks above the latest observed safe head. - blocks: VecDeque, + blocks: VecDeque, /// Index into `blocks`: next block not yet appended to the writable channel tail. block_cursor: usize, /// Hash of the last accepted block or safe-head anchor. @@ -90,17 +89,6 @@ impl BatchEncoder { }) } - /// Estimate the DA bytes represented by non-deposit transactions in `block`. - fn block_da_backlog_bytes(block: &BaseBlock) -> u64 { - block - .body - .transactions - .iter() - .filter(|tx| !matches!(tx, BaseTxEnvelope::Deposit(_))) - .map(|tx| tx.encode_2718_len() as u64) - .sum() - } - /// Steps until idle, flushes the writable channel, and drains all ready submissions. /// /// # Errors @@ -356,7 +344,7 @@ impl BatchEncoder { return true; }; - let oldest_number = oldest.header.number; + let oldest_number = oldest.block().header.number; let next_safe = safe_l2.number + 1; if next_safe < oldest_number { return false; @@ -368,9 +356,9 @@ impl BatchEncoder { } if prune_count == 0 { - return oldest.header.parent_hash == safe_l2.hash; + return oldest.block().header.parent_hash == safe_l2.hash; } - if self.blocks[prune_count - 1].header.hash_slow() != safe_l2.hash { + if self.blocks[prune_count - 1].block().header.hash_slow() != safe_l2.hash { return false; } @@ -426,7 +414,7 @@ impl BatchEncoder { .end .checked_sub(1) .and_then(|last_block_index| self.blocks.get(last_block_index)) - .is_some_and(|last_block| safe_l2.number < last_block.header.number) + .is_some_and(|last_block| safe_l2.number < last_block.block().header.number) }) } @@ -454,7 +442,7 @@ impl BatchPipeline for BatchEncoder { let number = block.header.number; let hash = block.header.hash_slow(); self.tip = Some(hash); - self.blocks.push_back(block); + self.blocks.push_back(BufferedBlock::new(block)); BatcherMetrics::pending_blocks().increment(1.0); debug!(block = %number, pending_blocks = %self.blocks.len(), "block added to encoder queue"); @@ -476,11 +464,11 @@ impl BatchPipeline for BatchEncoder { return Ok(StepResult::Idle); } - let block = &self.blocks[self.block_cursor]; - let block_da_backlog_bytes = Self::block_da_backlog_bytes(block); + let buffered = &self.blocks[self.block_cursor]; + let block_da_backlog_bytes = buffered.estimated_da_bytes(); // Composition failure is fatal: skipping the block would gap the L2 sequence. - let single_batch = BatchComposer::block_to_single_batch(block) + let single_batch = BatchComposer::block_to_single_batch(buffered.block()) .map_err(|source| StepError::CompositionFailed { cursor: self.block_cursor, source })?; if !self.channels.back().is_some_and(Channel::is_open) { @@ -648,7 +636,7 @@ impl BatchPipeline for BatchEncoder { fn da_backlog_bytes(&self) -> u64 { let pending_blocks: u64 = - self.blocks.iter().skip(self.block_cursor).map(Self::block_da_backlog_bytes).sum(); + self.blocks.iter().skip(self.block_cursor).map(BufferedBlock::estimated_da_bytes).sum(); let channels: u64 = self .channels .iter() diff --git a/crates/batcher/encoder/src/lib.rs b/crates/batcher/encoder/src/lib.rs index ae67b05bb3..cea414cc7a 100644 --- a/crates/batcher/encoder/src/lib.rs +++ b/crates/batcher/encoder/src/lib.rs @@ -32,6 +32,9 @@ pub use egress::DaEgress; mod pipeline; pub use pipeline::{BatchPipeline, DerivationReconciliation, ReorgError, StepError, StepResult}; +mod buffered_block; +pub use buffered_block::BufferedBlock; + mod encoder; pub use encoder::BatchEncoder; diff --git a/crates/batcher/encoder/src/metrics.rs b/crates/batcher/encoder/src/metrics.rs index e06e23a876..a85af14bd5 100644 --- a/crates/batcher/encoder/src/metrics.rs +++ b/crates/batcher/encoder/src/metrics.rs @@ -89,7 +89,8 @@ impl BatcherMetrics { /// A parent-hash mismatch surfaced while adding a block to the pipeline. pub const RESET_INGEST_REORG: &'static str = "ingest_reorg"; - /// The derivation status reported a safe head that moved back or changed hash. + /// The derivation status reported a safe head that went back, from a rollup node that has + /// read L1 past the block the last safe head was reported at. pub const RESET_SAFE_HEAD_REORG: &'static str = "safe_head_reorg"; /// The derived safe head does not match the buffered chain. diff --git a/crates/batcher/encoder/src/pipeline.rs b/crates/batcher/encoder/src/pipeline.rs index 1189765423..6bbbc61f99 100644 --- a/crates/batcher/encoder/src/pipeline.rs +++ b/crates/batcher/encoder/src/pipeline.rs @@ -114,8 +114,8 @@ pub trait BatchPipeline: Send { /// Estimated DA bytes still awaiting confirmation. /// - /// Unencoded queued blocks plus channels that are not fully confirmed. - /// Deposits are excluded. + /// Unencoded queued blocks plus channels that are not fully confirmed, each block counted + /// at its [`BufferedBlock`](crate::BufferedBlock) estimate. fn da_backlog_bytes(&self) -> u64; /// Force blob DA on later submissions. No-op unless `da_type` is calldata. diff --git a/crates/batcher/encoder/tests/encoder/backlog.rs b/crates/batcher/encoder/tests/encoder/backlog.rs index cbf6a01cae..7f4130cb9a 100644 --- a/crates/batcher/encoder/tests/encoder/backlog.rs +++ b/crates/batcher/encoder/tests/encoder/backlog.rs @@ -1,27 +1,26 @@ //! The DA backlog reported by `BatchEncoder`. -use alloy_eips::eip2718::Encodable2718; use alloy_primitives::B256; -use base_batcher_encoder::{BatchPipeline, EncoderConfig, StepResult}; +use base_batcher_encoder::{BatchPipeline, BufferedBlock, EncoderConfig, StepResult}; use crate::common::{BlockFixture, EncoderFixture, OpenChannel, SubmissionFixture}; -/// The backlog is the bytes of the user transactions, deposits excluded, from the moment a -/// block is queued until its channel is fully confirmed. +/// The backlog is the estimated DA size of a block from the moment it is queued until its +/// channel is fully confirmed. #[test] -fn backlog_counts_user_transaction_bytes_until_confirmation() { +fn backlog_counts_the_estimated_da_bytes_until_confirmation() { let fixture = EncoderFixture::new(EncoderConfig::default()); let mut encoder = fixture.encoder(); let block = BlockFixture::block(B256::ZERO, 1, 0); - let user_tx_bytes = block.body.transactions[1].encode_2718_len() as u64; + let block_da_bytes = BufferedBlock::new(block.clone()).estimated_da_bytes(); encoder.add_block(block).unwrap(); - assert_eq!(encoder.da_backlog_bytes(), user_tx_bytes); + assert_eq!(encoder.da_backlog_bytes(), block_da_bytes); assert_eq!(encoder.step().unwrap(), StepResult::BlockEncoded); - assert_eq!(encoder.da_backlog_bytes(), user_tx_bytes, "encoding changes nothing"); + assert_eq!(encoder.da_backlog_bytes(), block_da_bytes, "encoding changes nothing"); let submissions = encoder.encode_and_drain().unwrap(); - assert_eq!(encoder.da_backlog_bytes(), user_tx_bytes, "in flight changes nothing"); + assert_eq!(encoder.da_backlog_bytes(), block_da_bytes, "in flight changes nothing"); for submission in submissions { encoder.confirm(submission.id, 1); diff --git a/crates/batcher/service/Cargo.toml b/crates/batcher/service/Cargo.toml index 9e9eabb14f..43399c9ccc 100644 --- a/crates/batcher/service/Cargo.toml +++ b/crates/batcher/service/Cargo.toml @@ -16,12 +16,14 @@ url.workspace = true eyre.workspace = true backon.workspace = true futures.workspace = true +reqwest.workspace = true tokio-util.workspace = true async-trait.workspace = true base-retry.workspace = true base-metrics.workspace = true base-tx-manager.workspace = true alloy-sol-types.workspace = true +alloy-rpc-client.workspace = true base-batcher-core.workspace = true base-batcher-admin.workspace = true base-balance-monitor.workspace = true @@ -31,7 +33,6 @@ base-batcher-encoder.workspace = true metrics = { workspace = true, optional = true } tracing = { workspace = true, features = ["std"] } derive_more = { workspace = true, features = ["debug"] } -base-protocol = { workspace = true, features = ["std"] } base-runtime = { workspace = true, features = ["tokio"] } alloy-rpc-types-eth = { workspace = true, features = ["std"] } base-common-consensus = { workspace = true, features = ["std"] } @@ -48,9 +49,10 @@ httpmock.workspace = true serde_json.workspace = true alloy-node-bindings.workspace = true jsonrpsee = { workspace = true, features = ["server"] } +base-protocol = { workspace = true, features = ["std"] } base-runtime = { workspace = true, features = ["test-utils"] } base-common-genesis = { workspace = true, features = ["serde"] } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } [features] metrics = [ diff --git a/crates/batcher/service/src/config.rs b/crates/batcher/service/src/config.rs index f62edd25d4..ae9abb148d 100644 --- a/crates/batcher/service/src/config.rs +++ b/crates/batcher/service/src/config.rs @@ -16,7 +16,7 @@ use url::Url; /// the parity validator of [`shadow`](Self::shadow). #[derive(Debug, Clone)] pub struct BatcherConfig { - /// L1 RPC endpoint. + /// L1 HTTP RPC endpoint. pub l1_rpc_url: Url, /// Sequencer HTTP endpoints, at least one: conductors with their RPC proxy enabled, or /// consensus nodes that forward the methods they do not serve to their execution client. @@ -28,10 +28,10 @@ pub struct BatcherConfig { pub sequencer_urls: Vec, /// Optional L1 WebSocket endpoint for new-block subscriptions. /// - /// When set, the batcher subscribes to new L1 block headers over this - /// connection to advance the pipeline's L1 head, falling back to polling - /// [`l1_rpc_url`](Self::l1_rpc_url) only on failure. When absent, polling - /// is used exclusively. + /// When set, the batcher also takes new L1 heads from a subscription over this connection, + /// alongside polling [`l1_rpc_url`](Self::l1_rpc_url). A connection or subscription that + /// fails or outlasts [`network_timeout`](Self::network_timeout) leaves polling alone. When + /// absent, polling is used exclusively. pub l1_ws_url: Option, /// Signer configuration for signing L1 transactions. /// @@ -46,12 +46,22 @@ pub struct BatcherConfig { pub shadow: Option, /// Polling interval. pub poll_interval: Duration, + /// Timeout of the RPC calls to L1, the sequencers and the parity validator. + pub network_timeout: Duration, /// Encoder configuration. pub encoder_config: EncoderConfig, /// Maximum number of in-flight (unconfirmed) transactions. pub max_pending_transactions: usize, - /// Transaction manager configuration. - pub tx_manager: TxManagerConfig, + /// L1 confirmations a submission waits for. + pub num_confirmations: u64, + /// Time after which an unconfirmed submission is resubmitted with a higher fee. Twice this + /// value also bounds the drain of in-flight submissions at shutdown. + pub resubmission_timeout: Duration, + /// Times the first publication of a submission is retried when an RPC rejects its nonce as + /// too high, as it temporarily does for an ordered nonce. + pub publish_max_retries: usize, + /// Delay between those retries. + pub publish_retry_delay: Duration, /// DA throttle configuration, `None` to disable the throttle. /// /// Must be `None` when [`shadow`](Self::shadow) is set. @@ -108,9 +118,13 @@ impl Default for BatcherConfig { metrics_enabled: false, shadow: None, poll_interval: Duration::from_secs(1), + network_timeout: Duration::from_secs(10), encoder_config: EncoderConfig::default(), max_pending_transactions: 1, - tx_manager: TxManagerConfig { num_confirmations: 1, ..TxManagerConfig::default() }, + num_confirmations: 1, + resubmission_timeout: Duration::from_secs(48), + publish_max_retries: 10, + publish_retry_delay: Duration::from_secs(1), throttle: Some(ThrottleConfig::default()), throttle_strategy: ThrottleStrategy::Quadratic, check_recent_txs_depth: 0, @@ -124,6 +138,19 @@ impl Default for BatcherConfig { } impl BatcherConfig { + /// The transaction manager configuration: the settings the batcher exposes, its + /// [`network_timeout`](Self::network_timeout), and the tx manager's defaults for the rest. + pub fn tx_manager_config(&self) -> TxManagerConfig { + TxManagerConfig { + num_confirmations: self.num_confirmations, + resubmission_timeout: self.resubmission_timeout, + publish_max_retries: self.publish_max_retries, + publish_retry_delay: self.publish_retry_delay, + network_timeout: self.network_timeout, + ..TxManagerConfig::default() + } + } + /// The throttle controller the batcher starts with: the configured /// [`throttle_strategy`](Self::throttle_strategy) and [`throttle`](Self::throttle) config, or /// [`ThrottleController::disabled`] when `throttle` is `None`. @@ -143,8 +170,8 @@ pub struct ShadowConfig { /// Rollup node RPC endpoint of the parity validator, whose rollup config the batcher reads /// and whose derivation it follows. pub validator_rollup_rpc: Url, - /// L2 RPC endpoint of the parity validator, whose derived block hashes are compared with - /// the leader sequencer's. + /// L2 HTTP RPC endpoint of the parity validator, whose derived block hashes are compared + /// with the leader sequencer's. pub validator_l2_rpc: Url, } @@ -176,6 +203,32 @@ mod tests { const CANONICAL_INBOX: Address = Address::repeat_byte(0xca); const SHADOW_INBOX: Address = Address::repeat_byte(0x5a); + /// The tx manager gets the settings the batcher exposes and the batcher's network timeout, + /// and keeps its own defaults for the rest. + #[test] + fn tx_manager_config_carries_the_exposed_settings_and_the_network_timeout() { + let config = BatcherConfig { + num_confirmations: 3, + resubmission_timeout: Duration::from_secs(30), + publish_max_retries: 7, + publish_retry_delay: Duration::from_secs(2), + network_timeout: Duration::from_secs(4), + ..BatcherConfig::default() + }; + + assert_eq!( + config.tx_manager_config(), + TxManagerConfig { + num_confirmations: 3, + resubmission_timeout: Duration::from_secs(30), + publish_max_retries: 7, + publish_retry_delay: Duration::from_secs(2), + network_timeout: Duration::from_secs(4), + ..TxManagerConfig::default() + } + ); + } + /// The batcher starts with the configured throttle strategy and config, and with the off /// strategy when the throttle is disabled. #[test] diff --git a/crates/batcher/service/src/derivation_status_poller.rs b/crates/batcher/service/src/derivation_status_poller.rs index 38005d1a86..b2545d6fd3 100644 --- a/crates/batcher/service/src/derivation_status_poller.rs +++ b/crates/batcher/service/src/derivation_status_poller.rs @@ -3,7 +3,6 @@ use std::{future::Future, time::Duration}; use base_batcher_core::DerivationStatus; -use base_protocol::BlockInfo; use base_runtime::Runtime; use tokio::sync::mpsc; use tracing::warn; @@ -20,8 +19,8 @@ pub trait DerivationStatusProvider: Send + Sync + 'static { /// Polls a provider and sends every derivation-status change in observation order. /// -/// Statuses without a safe L2 head are skipped. A starting node reports them until its engine -/// is bootstrapped, and the driver would take them for a safe head back at genesis. +/// Statuses without a safe L2 head or an L1 block are skipped: the driver would take them for a +/// safe head back at genesis or derivation back at L1 block 0. #[derive(Debug)] pub struct DerivationStatusPoller { provider: C, @@ -59,8 +58,8 @@ impl DerivationStatusPoller { }; match result { - Ok(status) if status.safe_l2 == BlockInfo::default() => { - warn!("derivation status without a safe L2 head, node still starting"); + Ok(status) if status.lacks_safe_head_or_l1_block() => { + warn!("derivation status without a safe L2 head or an L1 block, skipping"); } Ok(status) if status != self.last_status => { tokio::select! { @@ -149,15 +148,17 @@ mod tests { }); } - /// The poller skips the empty safe head a starting node reports until its engine is - /// bootstrapped. + /// The poller skips the statuses without a safe L2 head or without an L1 block. #[test] - fn skips_a_status_without_a_safe_head() { + fn skips_the_statuses_without_a_safe_head_or_an_l1_block() { Runner::start(Config::seeded(0), |ctx| async move { let (tx, mut rx) = mpsc::channel(1); - let starting = DerivationStatus { safe_l2: BlockInfo::default(), current_l1: head(1) }; + let no_safe_head = + DerivationStatus { safe_l2: BlockInfo::default(), current_l1: head(1) }; + let no_l1_block = + DerivationStatus { safe_l2: head(5), current_l1: BlockInfo::default() }; let provider = MockProvider { - statuses: Mutex::new(VecDeque::from([starting, starting])), + statuses: Mutex::new(VecDeque::from([no_safe_head, no_l1_block])), fallback: status(10, 2), }; let poller = diff --git a/crates/batcher/service/src/l2_block_parity.rs b/crates/batcher/service/src/l2_block_parity.rs index ad420be737..75371db19c 100644 --- a/crates/batcher/service/src/l2_block_parity.rs +++ b/crates/batcher/service/src/l2_block_parity.rs @@ -232,13 +232,13 @@ where /// Comparable L2 blocks the cursor has not reached yet. /// - /// Measured against the common unsafe head because a pass never walks past - /// `min(sequencer, validator)`: a validator trailing the sequencer is already reported by - /// `validator_unsafe_lag_blocks`, so what is left here is the monitor's own progress. A pass + /// Measured against the comparable head, the lower of the sequencer's unsafe head and the + /// validator's safe head, because a pass never walks past it: blocks the validator has not + /// derived yet are its own lag, so what is left here is the monitor's own progress. A pass /// advances by at most `max_blocks_per_tick`, so this grows both while the monitor is /// catching up and while the cursor is parked on a block it cannot fetch. - pub const fn verification_backlog(&self, common_unsafe: u64) -> u64 { - common_unsafe.saturating_add(1).saturating_sub(self.next_block) + pub const fn verification_backlog(&self, comparable_head: u64) -> u64 { + comparable_head.saturating_add(1).saturating_sub(self.next_block) } /// Runs the monitor loop until cancelled. @@ -286,27 +286,27 @@ where Ok(number) => L2BlockParityMetrics::sequencer_safe_l2_block().set(number as f64), Err(error) => warn!(error = %error, "failed to fetch sequencer safe L2 head"), } - match self.validator.safe_block_number().await { - Ok(number) => L2BlockParityMetrics::validator_safe_l2_block().set(number as f64), - Err(error) => warn!(error = %error, "failed to fetch validator safe L2 head"), - } + // The validator's unsafe blocks can arrive by gossip from the sequencer, so only its safe + // blocks, derived from the shadow inbox, show what the batcher posted. + let validator_safe = self.validator.safe_block_number().await?; + L2BlockParityMetrics::validator_safe_l2_block().set(validator_safe as f64); - let common_unsafe = sequencer_unsafe.min(validator_unsafe); + let comparable_head = sequencer_unsafe.min(validator_safe); L2BlockParityMetrics::verification_backlog_blocks() - .set(self.verification_backlog(common_unsafe) as f64); + .set(self.verification_backlog(comparable_head) as f64); - if common_unsafe < self.next_block { + if comparable_head < self.next_block { debug!( next_block = %self.next_block, sequencer_unsafe = %sequencer_unsafe, - validator_unsafe = %validator_unsafe, - "waiting for parity validator to reach next comparable L2 block" + validator_safe = %validator_safe, + "waiting for parity validator to derive next comparable L2 block" ); return Ok(L2BlockParityStats::default()); } - let last_block = - common_unsafe.min(self.next_block.saturating_add(self.config.max_blocks_per_tick - 1)); + let last_block = comparable_head + .min(self.next_block.saturating_add(self.config.max_blocks_per_tick - 1)); let mut stats = L2BlockParityStats::default(); for number in self.next_block..=last_block { @@ -330,7 +330,7 @@ where } L2BlockParityMetrics::verification_backlog_blocks() - .set(self.verification_backlog(common_unsafe) as f64); + .set(self.verification_backlog(comparable_head) as f64); if stats.checked > 0 || stats.missing_blocks > 0 { debug!( @@ -340,7 +340,7 @@ where missing_blocks = %stats.missing_blocks, next_block = %self.next_block, sequencer_unsafe = %sequencer_unsafe, - validator_unsafe = %validator_unsafe, + validator_safe = %validator_safe, "derived L2 block parity pass processed" ); } @@ -518,7 +518,7 @@ mod tests { assert_eq!(monitor.next_block, 2); } - /// A block above the validator's unsafe head is not compared yet, so the monitor waits for it + /// A block above the validator's safe head is not compared yet, so the monitor waits for it /// instead of skipping it. #[tokio::test] async fn process_once_waits_for_validator_to_reach_next_block() { @@ -535,18 +535,55 @@ mod tests { assert_eq!(monitor.next_block, 4); } - /// The verification backlog counts the blocks from the cursor up to the common unsafe head, and + /// Blocks the validator holds as unsafe but has not derived are not compared, since gossip + /// from the sequencer can supply them. + #[tokio::test] + async fn process_once_compares_only_blocks_the_validator_derived() { + let blocks = [snapshot(1, 1, &[10]), snapshot(2, 2, &[20]), snapshot(3, 3, &[30])]; + let mut validator = MockL2BlockProvider::new(); + validator.expect_unsafe_block_number().returning(|| Ok(3)); + validator.expect_safe_block_number().returning(|| Ok(1)); + let served = blocks.clone(); + validator.expect_block_by_number().returning(move |number| { + Ok(served.iter().find(|block| block.number == number).cloned()) + }); + let config = L2BlockParityMonitorConfig::new(1, Duration::from_secs(1)); + let mut monitor = L2BlockParityMonitor::new(provider(3, blocks), validator, config); + + let stats = monitor.process_once().await.unwrap(); + + assert_eq!(stats, L2BlockParityStats { checked: 1, matches: 1, ..Default::default() }); + assert_eq!(monitor.next_block, 2); + } + + /// A pass that cannot read the validator's safe head compares nothing and keeps its cursor. + #[tokio::test] + async fn process_once_fails_without_the_validator_safe_head() { + let blocks = [snapshot(1, 1, &[10])]; + let mut validator = MockL2BlockProvider::new(); + validator.expect_unsafe_block_number().returning(|| Ok(1)); + validator + .expect_safe_block_number() + .returning(|| Err(eyre::eyre!("safe head unavailable"))); + let config = L2BlockParityMonitorConfig::new(1, Duration::from_secs(1)); + let mut monitor = L2BlockParityMonitor::new(provider(1, blocks), validator, config); + + assert!(monitor.process_once().await.is_err()); + assert_eq!(monitor.next_block, 1); + } + + /// The verification backlog counts the blocks from the cursor up to the comparable head, and /// is zero while the validator is behind the cursor. #[test] - fn verification_backlog_counts_blocks_left_below_the_common_unsafe_head() { + fn verification_backlog_counts_blocks_left_below_the_comparable_head() { let config = L2BlockParityMonitorConfig::new(7, Duration::from_secs(1)); let monitor = L2BlockParityMonitor::new(provider(9, []), provider(9, []), config); // Blocks 7, 8 and 9 are comparable and none has been compared yet. assert_eq!(monitor.verification_backlog(9), 3); - // Once caught up, the cursor sits one past the common unsafe head. + // Once caught up, the cursor sits one past the comparable head. assert_eq!(monitor.verification_backlog(6), 0); - // A common unsafe head below the cursor is a validator that has not caught up, + // A comparable head below the cursor is a validator that has not caught up, // not backlog. assert_eq!(monitor.verification_backlog(2), 0); } diff --git a/crates/batcher/service/src/lib.rs b/crates/batcher/service/src/lib.rs index a6549faa01..7b73a74d1a 100644 --- a/crates/batcher/service/src/lib.rs +++ b/crates/batcher/service/src/lib.rs @@ -37,6 +37,9 @@ pub use throttle::ThrottlePusher; mod derivation_status_poller; pub use derivation_status_poller::{DerivationStatusPoller, DerivationStatusProvider}; +mod rpc_client_builder; +pub use rpc_client_builder::RpcClientBuilder; + mod sequencers; pub use sequencers::{Sequencer, Sequencers}; diff --git a/crates/batcher/service/src/rollup_node.rs b/crates/batcher/service/src/rollup_node.rs index 2fec9b43b0..508e30c47e 100644 --- a/crates/batcher/service/src/rollup_node.rs +++ b/crates/batcher/service/src/rollup_node.rs @@ -4,9 +4,9 @@ use std::sync::Arc; use base_batcher_core::DerivationStatus; use base_consensus_rpc::RollupNodeApiClient; -use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; +use jsonrpsee::http_client::HttpClient; -use crate::{DerivationStatusProvider, Sequencers, ShadowConfig}; +use crate::{DerivationStatusProvider, RpcClientBuilder, Sequencers, ShadowConfig}; /// The rollup node whose rollup config the batcher reads and whose derivation it follows. #[derive(Debug)] @@ -24,20 +24,17 @@ impl RollupNode { /// # Errors /// /// Returns an error when the parity validator URL is not an HTTP URL. - pub fn new(shadow: Option<&ShadowConfig>, sequencers: Arc) -> eyre::Result { - let Some(shadow) = shadow else { - return Ok(Self::Leader(sequencers)); - }; - - let origin = shadow.validator_rollup_rpc.origin().ascii_serialization(); - let client = HttpClientBuilder::default() - .build(shadow.validator_rollup_rpc.as_str()) - .map_err(|e| { - eyre::eyre!( - "failed to build the parity validator rollup node client for {origin}: {e}" - ) - })?; - Ok(Self::ParityValidator(client)) + pub fn new( + shadow: Option<&ShadowConfig>, + sequencers: Arc, + client_builder: RpcClientBuilder, + ) -> eyre::Result { + match shadow { + Some(shadow) => { + Ok(Self::ParityValidator(client_builder.client(&shadow.validator_rollup_rpc)?)) + } + None => Ok(Self::Leader(sequencers)), + } } /// The client of the rollup node: for [`Leader`](Self::Leader), the client of the current @@ -68,7 +65,7 @@ mod tests { use jsonrpsee::{core::client::ClientT, rpc_params}; use super::*; - use crate::test_utils::{Activity, FakeSequencer}; + use crate::test_utils::{Activity, FakeSequencer, rpc_client_builder}; /// A canonical batcher's rollup node is the one of the current leader, so a request made /// after a leader change reaches the new leader. @@ -76,10 +73,13 @@ mod tests { async fn leader_rollup_node_is_the_current_leader() { let first = FakeSequencer::start(Activity::Active, 1).await; let second = FakeSequencer::start(Activity::NotLeader, 2).await; - let sequencers = - Arc::new(Sequencers::new(&[first.url.clone(), second.url.clone()]).await.unwrap()); + let sequencers = Arc::new( + Sequencers::new(&[first.url.clone(), second.url.clone()], rpc_client_builder()) + .unwrap(), + ); sequencers.refresh_leader().await.unwrap(); - let rollup_node = RollupNode::new(None, Arc::clone(&sequencers)).unwrap(); + let rollup_node = + RollupNode::new(None, Arc::clone(&sequencers), rpc_client_builder()).unwrap(); first.set_activity(Activity::NotLeader); second.set_activity(Activity::Active); diff --git a/crates/batcher/service/src/rpc_client_builder.rs b/crates/batcher/service/src/rpc_client_builder.rs new file mode 100644 index 0000000000..de418ec4af --- /dev/null +++ b/crates/batcher/service/src/rpc_client_builder.rs @@ -0,0 +1,132 @@ +//! Builds the JSON-RPC clients of the batcher. + +use std::time::Duration; + +use alloy_provider::{RootProvider, network::Network}; +use alloy_rpc_client::RpcClient; +use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; +use url::Url; + +/// Builds the JSON-RPC clients of the batcher. +/// +/// Every request of every client fails once the network timeout elapses, so an endpoint that +/// stops answering never holds the batcher. +#[derive(Debug, Clone, Copy)] +pub struct RpcClientBuilder { + network_timeout: Duration, +} + +impl RpcClientBuilder { + /// Creates a builder of clients whose requests time out after `network_timeout`. + pub const fn new(network_timeout: Duration) -> Self { + Self { network_timeout } + } + + /// An alloy provider of the HTTP endpoint at `url`, which carries the typed Ethereum API, + /// the `eth_*` methods. + /// + /// # Errors + /// + /// Returns an error when `url` is not an HTTP URL, or when reqwest cannot initialize its + /// TLS backend. + pub fn provider(&self, url: &Url) -> eyre::Result> { + let origin = url.origin().ascii_serialization(); + if !matches!(url.scheme(), "http" | "https") { + eyre::bail!("{origin} is not an HTTP URL"); + } + let http = reqwest::Client::builder() + .timeout(self.network_timeout) + .build() + .map_err(|e| eyre::eyre!("failed to build the provider for {origin}: {e}"))?; + Ok(RootProvider::new(RpcClient::new_http_with_client(http, url.clone()))) + } + + /// A jsonrpsee client of the HTTP endpoint at `url`, for the APIs defined as jsonrpsee + /// traits: `optimism_*`, `admin_*` and `miner_*`. + /// + /// # Errors + /// + /// Returns an error when `url` is not an HTTP URL. + pub fn client(&self, url: &Url) -> eyre::Result { + HttpClientBuilder::default() + .request_timeout(self.network_timeout) + .build(url.as_str()) + .map_err(|e| { + let origin = url.origin().ascii_serialization(); + eyre::eyre!("failed to build the client for {origin}: {e}") + }) + } +} + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use alloy_provider::{Provider, network::Ethereum}; + use jsonrpsee::{ + core::{ClientError, client::ClientT}, + rpc_params, + }; + + use super::*; + + const NETWORK_TIMEOUT: Duration = Duration::from_secs(10); + + /// The URL of an endpoint that accepts connections but never answers, and the listener + /// that keeps it open. + fn silent_endpoint() -> (Url, TcpListener) { + let silent = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", silent.local_addr().unwrap()).parse().unwrap(); + (url, silent) + } + + /// A request through a provider of an endpoint that accepts the connection but never + /// answers fails once the network timeout elapses. + #[tokio::test(start_paused = true)] + async fn a_provider_request_fails_when_the_endpoint_never_answers() { + let (silent_url, _silent) = silent_endpoint(); + let provider: RootProvider = + RpcClientBuilder::new(NETWORK_TIMEOUT).provider(&silent_url).unwrap(); + + let error = tokio::time::timeout(NETWORK_TIMEOUT * 2, provider.get_chain_id()) + .await + .expect("the request must fail instead of hanging") + .unwrap_err(); + + let timed_out = error + .as_transport_err() + .and_then(|error| error.as_custom()) + .and_then(|error| error.downcast_ref::()) + .is_some_and(reqwest::Error::is_timeout); + assert!(timed_out, "{error}"); + } + + /// A request through a client of an endpoint that accepts the connection but never answers + /// fails once the network timeout elapses. + #[tokio::test(start_paused = true)] + async fn a_client_request_fails_when_the_endpoint_never_answers() { + let (silent_url, _silent) = silent_endpoint(); + let client = RpcClientBuilder::new(NETWORK_TIMEOUT).client(&silent_url).unwrap(); + + let error = tokio::time::timeout( + NETWORK_TIMEOUT * 2, + client.request::("eth_chainId", rpc_params![]), + ) + .await + .expect("the request must fail instead of hanging") + .unwrap_err(); + + assert!(matches!(error, ClientError::RequestTimeout), "{error}"); + } + + /// Neither client is built for a non-HTTP URL, so a wrong scheme fails at startup instead + /// of on every request. + #[test] + fn no_client_is_built_for_a_non_http_url() { + let url: Url = "ws://127.0.0.1:1".parse().unwrap(); + let builder = RpcClientBuilder::new(NETWORK_TIMEOUT); + + assert!(builder.provider::(&url).is_err()); + assert!(builder.client(&url).is_err()); + } +} diff --git a/crates/batcher/service/src/sequencers.rs b/crates/batcher/service/src/sequencers.rs index 0ec138be13..46e4d2c36c 100644 --- a/crates/batcher/service/src/sequencers.rs +++ b/crates/batcher/service/src/sequencers.rs @@ -13,13 +13,12 @@ use base_common_network::Base; use base_consensus_rpc::AdminApiClient; use base_runtime::Runtime; use futures::future; -use jsonrpsee::{ - core::ClientError, - http_client::{HttpClient, HttpClientBuilder}, -}; +use jsonrpsee::{core::ClientError, http_client::HttpClient}; use tracing::{info, warn}; use url::Url; +use crate::RpcClientBuilder; + /// One sequencer endpoint, which serves both the rollup node methods and the L2 blocks. #[derive(derive_more::Debug)] pub struct Sequencer { @@ -35,17 +34,16 @@ pub struct Sequencer { impl Sequencer { /// Creates the clients of the sequencer endpoint at `url`. - pub async fn new(url: &Url) -> eyre::Result { - let origin = url.origin().ascii_serialization(); - - let rollup_node_client = HttpClientBuilder::default() - .build(url.as_str()) - .map_err(|e| eyre::eyre!("failed to build the rollup node client for {origin}: {e}"))?; - let l2_provider = RootProvider::connect(url.as_str()) - .await - .map_err(|e| eyre::eyre!("failed to build the L2 provider for {origin}: {e}"))?; - - Ok(Self { origin, rollup_node_client, l2_provider }) + /// + /// # Errors + /// + /// Returns an error when `url` is not an HTTP URL. + pub fn new(url: &Url, client_builder: RpcClientBuilder) -> eyre::Result { + Ok(Self { + origin: url.origin().ascii_serialization(), + rollup_node_client: client_builder.client(url)?, + l2_provider: client_builder.provider(url)?, + }) } } @@ -74,11 +72,14 @@ impl Sequencers { /// # Errors /// /// Returns an error when `urls` is empty or one of them is not an HTTP URL. - pub async fn new(urls: &[Url]) -> eyre::Result { + pub fn new(urls: &[Url], client_builder: RpcClientBuilder) -> eyre::Result { if urls.is_empty() { eyre::bail!("at least one sequencer URL is required"); } - let sequencers = future::try_join_all(urls.iter().map(Sequencer::new)).await?; + let sequencers = urls + .iter() + .map(|url| Sequencer::new(url, client_builder)) + .collect::>()?; Ok(Self { sequencers, leader_index: AtomicUsize::new(0) }) } @@ -171,7 +172,7 @@ mod tests { use base_runtime::{Cancellation, TokioRuntime}; use super::*; - use crate::test_utils::{Activity, FakeSequencer}; + use crate::test_utils::{Activity, FakeSequencer, rpc_client_builder}; /// The leader is the active sequencer, which the reads go to. A sequencer that is stopped, /// one that is not the leader behind its conductor and one that is unreachable are passed @@ -183,7 +184,7 @@ mod tests { let unreachable = "http://127.0.0.1:1".parse().unwrap(); let active = FakeSequencer::start(Activity::Active, 3).await; let urls = [stopped.url.clone(), not_leader.url.clone(), unreachable, active.url.clone()]; - let sequencers = Sequencers::new(&urls).await.unwrap(); + let sequencers = Sequencers::new(&urls, rpc_client_builder()).unwrap(); sequencers.refresh_leader().await.unwrap(); @@ -197,7 +198,8 @@ mod tests { let silent = TcpListener::bind("127.0.0.1:0").unwrap(); let silent_url = format!("http://{}", silent.local_addr().unwrap()).parse().unwrap(); let active = FakeSequencer::start(Activity::Active, 1).await; - let sequencers = Sequencers::new(&[silent_url, active.url.clone()]).await.unwrap(); + let sequencers = + Sequencers::new(&[silent_url, active.url.clone()], rpc_client_builder()).unwrap(); tokio::time::timeout( Sequencers::SEQUENCER_ACTIVE_RPC_TIMEOUT * 2, @@ -216,7 +218,9 @@ mod tests { async fn refresh_leader_fails_and_keeps_the_leader_when_no_sequencer_is_active() { let first = FakeSequencer::start(Activity::Stopped, 1).await; let second = FakeSequencer::start(Activity::Active, 2).await; - let sequencers = Sequencers::new(&[first.url.clone(), second.url.clone()]).await.unwrap(); + let sequencers = + Sequencers::new(&[first.url.clone(), second.url.clone()], rpc_client_builder()) + .unwrap(); sequencers.refresh_leader().await.unwrap(); second.set_activity(Activity::NotLeader); @@ -239,8 +243,10 @@ mod tests { async fn reads_go_to_the_current_leader() { let first = FakeSequencer::start(Activity::Active, 1).await; let second = FakeSequencer::start(Activity::NotLeader, 2).await; - let sequencers = - Arc::new(Sequencers::new(&[first.url.clone(), second.url.clone()]).await.unwrap()); + let sequencers = Arc::new( + Sequencers::new(&[first.url.clone(), second.url.clone()], rpc_client_builder()) + .unwrap(), + ); let runtime = TokioRuntime::new(); let tracking = tokio::spawn( Arc::clone(&sequencers).track_leader(runtime.clone(), Duration::from_millis(1)), diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index a0ae3e6f91..c2237b1405 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -1,11 +1,6 @@ //! Batcher service startup and wiring. -use std::{ - future::{Future, pending}, - sync::Arc, - task::Poll, - time::Duration, -}; +use std::{future::Future, sync::Arc, task::Poll, time::Duration}; use alloy_provider::{Provider, ProviderBuilder, ProviderLayer, RootProvider}; use backon::Retryable; @@ -16,7 +11,6 @@ use base_batcher_encoder::{BatchEncoder, BatcherMetrics}; use base_batcher_source::{HybridL1HeadSource, PollingBlockSource}; use base_common_network::Base; use base_consensus_rpc::RollupNodeApiClient; -use base_protocol::BlockInfo; use base_retry::{DEFAULT_UNBOUNDED_MAX_DELAY, RetryConfig}; use base_runtime::TokioRuntime; use base_tx_manager::{BaseTxMetrics, SimpleTxManager}; @@ -33,8 +27,8 @@ use url::Url; use crate::{ BatcherConfig, DerivationStatusPoller, DerivationStatusProvider, L2BlockParityMonitor, L2BlockParityMonitorConfig, MAX_CHECK_RECENT_TXS_DEPTH, RecentTxSyncTarget, RollupNode, - RpcL1HeadPollingSource, RpcL2BlockProvider, RpcPollingSource, Sequencers, SystemConfigBatcher, - ThrottlePusher, + RpcClientBuilder, RpcL1HeadPollingSource, RpcL2BlockProvider, RpcPollingSource, Sequencers, + SystemConfigBatcher, ThrottlePusher, }; const WEI_PER_ETHER: f64 = 1_000_000_000_000_000_000.0; @@ -71,25 +65,34 @@ pub struct ReadyBatcher { impl ReadyBatcher { /// Run the batch submission loop until the runtime is cancelled. + /// + /// The driver and the background tasks run until either exits, which cancels the others. + /// All are then awaited, so the driver always completes its shutdown drain. A background + /// task exiting is an error; the driver error takes precedence. pub async fn run(self) -> eyre::Result<()> { info!("batcher driver running"); let Self { driver, admin_server, background_tasks, cancellation } = self; - let background_cancellation = cancellation.clone(); - let background_task_exit = async move { + let run_driver = async { + let result = driver.run().await; + cancellation.cancel(); + result + }; + let watch_background_tasks = async { let mut background_tasks = background_tasks .into_iter() .map(|(task_name, task)| async move { (task_name, task.await) }) .collect::>(); - tokio::select! { + let result = tokio::select! { biased; - () = background_cancellation.cancelled() => {} + () = cancellation.cancelled() => Ok(()), Some((task_name, result)) = background_tasks.next(), if !background_tasks.is_empty() => { match result { - Ok(()) => eyre::bail!("{task_name} exited unexpectedly"), - Err(error) => eyre::bail!("{task_name} failed: {error}"), + Ok(()) => Err(eyre::eyre!("{task_name} exited unexpectedly")), + Err(error) => Err(eyre::eyre!("{task_name} failed: {error}")), } } - } + }; + cancellation.cancel(); while let Some((task_name, result)) = background_tasks.next().await { if let Err(error) = result { @@ -100,43 +103,22 @@ impl ReadyBatcher { ); } } - - Ok::<_, eyre::Report>(()) - }; - tokio::pin!(background_task_exit); - let driver_run = driver.run(); - tokio::pin!(driver_run); - let admin_stopped = async { - match admin_server.as_ref() { - Some(admin) => admin.stopped().await, - None => pending().await, - } + result }; - tokio::pin!(admin_stopped); - let mut admin_active = admin_server.is_some(); - - loop { + let watch_admin = async { + let Some(admin) = admin_server.as_ref() else { return }; tokio::select! { - r = &mut driver_run => { - cancellation.cancel(); - let driver_result = r; - let background_result = background_task_exit.as_mut().await; - driver_result?; - background_result?; - break; - } - r = &mut background_task_exit => { - cancellation.cancel(); - r?; - driver_run.await?; - break; - } - () = &mut admin_stopped, if admin_active => { - admin_active = false; + () = admin.stopped() => { warn!("admin server stopped unexpectedly; batcher continues without admin API"); } + () = cancellation.cancelled() => {} } - } + }; + + let (driver_result, background_result, ()) = + tokio::join!(run_driver, watch_background_tasks, watch_admin); + driver_result?; + background_result?; info!("batcher service shutting down"); Ok(()) } @@ -165,12 +147,16 @@ impl BatcherService { /// block headers and streams their block numbers. The stream owns the provider, so /// the connection lives as long as the stream does. /// - /// When `url` is `None`, or if connecting or subscribing fails, returns a stream that - /// never yields so that [`HybridL1HeadSource`] relies on polling alone. + /// When `url` is `None`, or if connecting or subscribing fails or outlasts + /// `network_timeout`, returns a stream that never yields so that [`HybridL1HeadSource`] + /// relies on polling alone. /// /// `l1_head_subscription_active` is 1 while the subscription streams heads, and 0 once /// the batcher relies on polling alone. - async fn build_l1_head_stream(url: Option<&Url>) -> BoxStream<'static, u64> { + async fn build_l1_head_stream( + url: Option<&Url>, + network_timeout: Duration, + ) -> BoxStream<'static, u64> { let active = BatcherMetrics::l1_head_subscription_active(); active.set(0.0); @@ -178,18 +164,30 @@ impl BatcherService { return stream::pending().boxed(); }; - let ws_provider = match ProviderBuilder::new().connect(url.as_str()).await { - Ok(p) => p, - Err(e) => { - warn!(error = %e, l1_ws = %url, "failed to connect L1 WS provider; falling back to polling"); + // The origin only: a hosted endpoint carries its API key in the path. + let origin = url.origin().ascii_serialization(); + let connect = ProviderBuilder::new().connect(url.as_str()); + let ws_provider = match tokio::time::timeout(network_timeout, connect).await { + Ok(Ok(provider)) => provider, + Ok(Err(error)) => { + warn!(error = %error, l1_ws = %origin, "failed to connect L1 WS provider; falling back to polling"); + return stream::pending().boxed(); + } + Err(_) => { + warn!(l1_ws = %origin, timeout = ?network_timeout, "L1 WS provider did not connect in time; falling back to polling"); return stream::pending().boxed(); } }; - let sub = match ws_provider.subscribe_blocks().await { - Ok(s) => s, - Err(e) => { - warn!(error = %e, "failed to subscribe to new L1 blocks; falling back to polling"); + let subscribe = ws_provider.subscribe_blocks(); + let sub = match tokio::time::timeout(network_timeout, subscribe).await { + Ok(Ok(sub)) => sub, + Ok(Err(error)) => { + warn!(error = %error, l1_ws = %origin, "failed to subscribe to new L1 blocks; falling back to polling"); + return stream::pending().boxed(); + } + Err(_) => { + warn!(l1_ws = %origin, timeout = ?network_timeout, "L1 WS provider did not subscribe in time; falling back to polling"); return stream::pending().boxed(); } }; @@ -297,11 +295,12 @@ impl BatcherService { sequencer_urls: &[Url], throttle: &DaThrottle, runtime: &TokioRuntime, + client_builder: RpcClientBuilder, ) -> eyre::Result> { sequencer_urls .iter() .map(|url| { - let pusher = ThrottlePusher::new(url, throttle.subscribe())?; + let pusher = ThrottlePusher::new(url, throttle.subscribe(), client_builder)?; let handle = tokio::spawn(pusher.run(runtime.clone())); Ok(("throttle pusher", handle.err_into().map(Result::flatten).boxed())) }) @@ -312,7 +311,7 @@ impl BatcherService { /// /// Requires a signer, finds the leader among the sequencers, fetches the rollup config, /// whose batch inbox the batcher posts to and must be the shadow inbox in shadow mode, - /// connects to L1, checks outside shadow mode that the signer is the batcher the L1 + /// checks outside shadow mode that the signer is the batcher the L1 /// `SystemConfig` authorizes, and constructs the driver. One-shot startup RPCs retry with /// exponential backoff until [`BatcherConfig::wait_node_sync_timeout`]. Returns an error if /// any of those steps fail. @@ -324,6 +323,8 @@ impl BatcherService { let cancel_on_failure = runtime.token().clone().drop_guard(); let mut background_tasks = Vec::new(); self.config.encoder_config.validate()?; + let tx_manager_config = self.config.tx_manager_config(); + tx_manager_config.validate()?; if let Some(throttle) = &self.config.throttle { throttle.validate()?; } @@ -360,10 +361,12 @@ impl BatcherService { ); } - let sequencers = Arc::new(Sequencers::new(&self.config.sequencer_urls).await?); + let client_builder = RpcClientBuilder::new(self.config.network_timeout); + let sequencers = Arc::new(Sequencers::new(&self.config.sequencer_urls, client_builder)?); // A canonical batcher follows the leader's rollup node, a shadow batcher the parity // validator's. - let rollup_node = RollupNode::new(self.config.shadow.as_ref(), Arc::clone(&sequencers))?; + let rollup_node = + RollupNode::new(self.config.shadow.as_ref(), Arc::clone(&sequencers), client_builder)?; let signer_config = self .config @@ -373,7 +376,7 @@ impl BatcherService { let signer_address = signer_config.address(); info!( - l1_ws = self.config.l1_ws_url.as_ref().map(|u| u.as_str()), + l1_ws = self.config.l1_ws_url.as_ref().map(|url| url.origin().ascii_serialization()), "starting batcher service" ); @@ -415,26 +418,14 @@ impl BatcherService { } let validator_provider = if let Some(shadow) = &self.config.shadow { - let provider = Self::rpc_retry("shadow.validator-l2-rpc", retry, rpc_timeout, || { - ProviderBuilder::new() - .disable_recommended_fillers() - .network::() - .connect(shadow.validator_l2_rpc.as_str()) - }) - .await?; - let provider: Arc + Send + Sync> = Arc::new(provider); + let provider: Arc + Send + Sync> = + Arc::new(client_builder.provider::(&shadow.validator_l2_rpc)?); Some(RpcL2BlockProvider::new(provider)) } else { None }; - // Connect to L1 before the optional node-sync gate. - let l1_provider: RootProvider = Self::rpc_retry("l1-rpc", retry, rpc_timeout, || { - ProviderBuilder::new() - .disable_recommended_fillers() - .connect(self.config.l1_rpc_url.as_str()) - }) - .await?; + let l1_provider: RootProvider = client_builder.provider(&self.config.l1_rpc_url)?; // Derivation ignores batches from any other sender, so a wrong signer would only burn L1 // fees. The shadow batcher posts with its own key on purpose. @@ -485,14 +476,16 @@ impl BatcherService { .await?; let initial_derivation_status = - Self::rpc_retry("optimism_syncStatus", retry, rpc_timeout, || { - rollup_node.derivation_status() + Self::rpc_retry("optimism_syncStatus", retry, rpc_timeout, || async { + match rollup_node.derivation_status().await { + Ok(status) if status.lacks_safe_head_or_l1_block() => { + Err("sync status without a safe L2 head or an L1 block".into()) + } + result => result, + } }) .await?; let safe_l2 = initial_derivation_status.safe_l2; - if safe_l2 == BlockInfo::default() { - eyre::bail!("safe L2 head is empty"); - } let next_l2_timestamp = safe_l2.timestamp.saturating_add(rollup_config.block_time); self.config.encoder_config.validate_for_rollup_config(&rollup_config, next_l2_timestamp)?; info!(safe_l2 = %safe_l2.number, "fetched safe L2 head"); @@ -557,7 +550,9 @@ impl BatcherService { let throttle = DaThrottle::new(self.config.throttle_controller()); // Build the L1 head source: a hybrid of optional WS subscription + polling. - let l1_head_stream = Self::build_l1_head_stream(self.config.l1_ws_url.as_ref()).await; + let l1_head_stream = + Self::build_l1_head_stream(self.config.l1_ws_url.as_ref(), self.config.network_timeout) + .await; let l1_head_poller = RpcL1HeadPollingSource::new(Arc::new(l1_provider.clone())); let l1_head_source = HybridL1HeadSource::new( TokioRuntime::new(), @@ -570,11 +565,11 @@ impl BatcherService { let l1_chain_id = Self::rpc_retry("l1-chain-id", retry, rpc_timeout, || l1_provider.get_chain_id()) .await?; - let drain_timeout = self.config.tx_manager.resubmission_timeout * 2; + let drain_timeout = self.config.resubmission_timeout * 2; let tx_manager = SimpleTxManager::new( l1_provider, signer_config, - self.config.tx_manager, + tx_manager_config, l1_chain_id, Arc::new(BaseTxMetrics::new("batcher")), ) @@ -587,6 +582,7 @@ impl BatcherService { &self.config.sequencer_urls, &throttle, &runtime, + client_builder, )?); } @@ -622,7 +618,7 @@ impl BatcherService { source, l1_head_source, initial_l1_head, - initial_safe_head: safe_l2, + initial_derivation_status, derivation_status_rx, admin_rx, }, @@ -645,13 +641,16 @@ impl BatcherService { #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicU8, Ordering}; + use std::{ + net::TcpListener, + sync::atomic::{AtomicU8, Ordering}, + }; use alloy_node_bindings::Anvil; use alloy_primitives::Address; use base_batcher_core::{ThrottleConfig, ThrottleController}; use base_common_genesis::RollupConfig; - use base_protocol::SyncStatus; + use base_protocol::{BlockInfo, L2BlockInfo, SyncStatus}; use base_runtime::Cancellation; use base_tx_manager::SignerConfig; use httpmock::{Mock, prelude::*}; @@ -661,7 +660,7 @@ mod tests { use super::*; use crate::{ ShadowConfig, - test_utils::{Activity, FakeSequencer}, + test_utils::{Activity, FakeSequencer, rpc_client_builder}, }; /// The `SystemConfig` address of the mocked rollup config. @@ -753,6 +752,10 @@ mod tests { BatcherConfig { poll_interval: Duration::ZERO, ..BatcherConfig::default() }, "poll_interval must be greater than zero" )] + #[case::zero_network_timeout( + BatcherConfig { network_timeout: Duration::ZERO, ..BatcherConfig::default() }, + "network_timeout must be > 0, got 0s" + )] #[case::zero_max_pending_transactions( BatcherConfig { max_pending_transactions: 0, ..BatcherConfig::default() }, "max_pending_transactions must be greater than zero: the batcher would never submit a \ @@ -868,6 +871,51 @@ mod tests { ); } + /// Setup reads the sync status again until it carries a safe head and an L1 block, instead of + /// failing on the first answer without them. + #[tokio::test] + async fn setup_waits_for_a_sync_status_with_a_safe_head_and_an_l1_block() { + let server = MockServer::start_async().await; + let signer = Address::repeat_byte(0x51); + mock_system_config(&server, signer).await; + mock_rpc(&server, r#"{"method":"eth_blockNumber"}"#, r#""0x1""#.into()).await; + let status = |current_l1| SyncStatus { + current_l1: BlockInfo { number: current_l1, ..Default::default() }, + local_safe_l2: L2BlockInfo { + block_info: BlockInfo { number: 5, ..Default::default() }, + ..Default::default() + }, + ..Default::default() + }; + // The rollup node client numbers its requests, `optimism_rollupConfig` being 0, and + // rejects an answer under another id, so its first two sync status reads are mocked as 1 + // and 2. + let mut reads = Vec::new(); + for (id, status) in [(1, status(0)), (2, status(7))] { + let response = format!( + r#"{{"jsonrpc":"2.0","id":{id},"result":{}}}"#, + serde_json::to_string(&status).unwrap() + ); + let request = format!(r#"{{"method":"optimism_syncStatus","id":{id}}}"#); + reads.push( + server + .mock_async(|when, then| { + when.method(POST).path("/").json_body_includes(request); + then.status(200).header("content-type", "application/json").body(response); + }) + .await, + ); + } + + // Setup fails later, on the encoder check against the mocked rollup config. + let _ = + BatcherService::new(mocked_config(&server, signer)).setup(TokioRuntime::new()).await; + + for read in &reads { + read.assert_calls_async(1).await; + } + } + /// Setup goes past the batcher check when the signer is the one the `SystemConfig` authorizes. #[tokio::test] async fn setup_accepts_the_signer_the_system_config_authorizes() { @@ -966,8 +1014,13 @@ mod tests { let throttle = DaThrottle::new(ThrottleController::disabled()); let runtime = TokioRuntime::new(); - let pushers = - BatcherService::spawn_throttle_pushers(&sequencer_urls, &throttle, &runtime).unwrap(); + let pushers = BatcherService::spawn_throttle_pushers( + &sequencer_urls, + &throttle, + &runtime, + rpc_client_builder(), + ) + .unwrap(); tokio::time::timeout(Duration::from_secs(5), async { for push in &pushes { @@ -992,7 +1045,11 @@ mod tests { #[tokio::test] async fn l1_head_stream_outlives_its_builder() { let anvil = Anvil::new().spawn(); - let mut heads = BatcherService::build_l1_head_stream(Some(&anvil.ws_endpoint_url())).await; + let mut heads = BatcherService::build_l1_head_stream( + Some(&anvil.ws_endpoint_url()), + BatcherConfig::default().network_timeout, + ) + .await; // The builder has returned, so the stream alone must keep the WS provider alive. let miner = RootProvider::::new_http(anvil.endpoint_url()); @@ -1002,4 +1059,24 @@ mod tests { assert_eq!(head.unwrap(), Some(expected)); } } + + /// An L1 WS endpoint that accepts the connection but never completes the handshake does not + /// hold the startup. Once the network timeout elapses the batcher falls back to polling. + #[tokio::test(start_paused = true)] + async fn l1_head_stream_falls_back_to_polling_when_the_ws_endpoint_never_answers() { + let silent = TcpListener::bind("127.0.0.1:0").unwrap(); + let silent_url = format!("ws://{}", silent.local_addr().unwrap()).parse().unwrap(); + let network_timeout = Duration::from_secs(10); + let started = tokio::time::Instant::now(); + + let _heads = tokio::time::timeout( + network_timeout * 2, + BatcherService::build_l1_head_stream(Some(&silent_url), network_timeout), + ) + .await + .expect("the build must give up instead of hanging"); + + // The fallback came from the timeout, not from a fast error. + assert!(started.elapsed() >= network_timeout); + } } diff --git a/crates/batcher/service/src/test_utils.rs b/crates/batcher/service/src/test_utils.rs index c0da38b34e..d7689ed37a 100644 --- a/crates/batcher/service/src/test_utils.rs +++ b/crates/batcher/service/src/test_utils.rs @@ -1,6 +1,7 @@ -//! A fake sequencer endpoint for the tests of the leader search. +//! A fake sequencer endpoint for the tests of the leader search, and the clients the tests build +//! it with. //! -//! Hand-rolled because [`Sequencers`](crate::Sequencers) builds concrete clients from URLs, a +//! The fake is hand-rolled because [`Sequencers`](crate::Sequencers) builds concrete clients from URLs, a //! jsonrpsee `HttpClient` and an alloy `RootProvider`, so there is no trait to automock. It is a //! real JSON-RPC server rather than an `httpmock` mock because the client rejects an answer whose //! id is not its request's, and these tests call one endpoint several times. @@ -18,6 +19,13 @@ use jsonrpsee::{ use serde_json::Value; use url::Url; +use crate::{BatcherConfig, RpcClientBuilder}; + +/// A builder of clients with the network timeout of [`BatcherConfig::default`]. +pub fn rpc_client_builder() -> RpcClientBuilder { + RpcClientBuilder::new(BatcherConfig::default().network_timeout) +} + /// What a [`FakeSequencer`] answers to `admin_sequencerActive`. #[derive(Debug, Clone, Copy)] pub enum Activity { diff --git a/crates/batcher/service/src/throttle.rs b/crates/batcher/service/src/throttle.rs index 72a100fe27..90a647f98c 100644 --- a/crates/batcher/service/src/throttle.rs +++ b/crates/batcher/service/src/throttle.rs @@ -6,15 +6,15 @@ use alloy_primitives::U64; use base_batcher_core::DaLimits; use base_runtime::Runtime; use jsonrpsee::{ - core::ClientError, - http_client::{HttpClient, HttpClientBuilder}, - proc_macros::rpc, + core::ClientError, http_client::HttpClient, proc_macros::rpc, types::error::METHOD_NOT_FOUND_CODE, }; use tokio::sync::watch; use tracing::{debug, warn}; use url::Url; +use crate::RpcClientBuilder; + /// Client-side jsonrpsee trait for the miner API extension. #[rpc(client, namespace = "miner")] trait MinerApiExt { @@ -29,7 +29,8 @@ trait MinerApiExt { /// The limits live in the block builder's memory, so a block builder that restarts loses them. /// The pusher pushes them on every publication and every /// [`REFRESH_INTERVAL`](Self::REFRESH_INTERVAL), which also retries a failed push and restores -/// the limits of a restarted block builder. +/// the limits of a restarted block builder. A push outlasts neither the network timeout nor, +/// therefore, newer limits and shutdown by more than that. /// /// A block builder that answers method not found does not serve the `miner` API. Retrying cannot /// fix that and the batcher cannot throttle it, so [`run`](Self::run) returns the error and the @@ -46,20 +47,21 @@ impl ThrottlePusher { /// How often the current limits are pushed while no new limits are published. pub const REFRESH_INTERVAL: Duration = Duration::from_secs(10); - /// How long one push may take, which bounds how long a stalled block builder holds back - /// newer limits and shutdown. - pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); - /// Creates a pusher of the limits published on `limits` to the block builder at `url`. - pub fn new(url: &Url, limits: watch::Receiver) -> eyre::Result { - let origin = url.origin().ascii_serialization(); - let client = HttpClientBuilder::default() - .request_timeout(Self::REQUEST_TIMEOUT) - .build(url.as_str()) - .map_err(|e| { - eyre::eyre!("failed to build the block builder client for {origin}: {e}") - })?; - Ok(Self { client, origin, limits }) + /// + /// # Errors + /// + /// Returns an error when `url` is not an HTTP URL. + pub fn new( + url: &Url, + limits: watch::Receiver, + client_builder: RpcClientBuilder, + ) -> eyre::Result { + Ok(Self { + client: client_builder.client(url)?, + origin: url.origin().ascii_serialization(), + limits, + }) } /// Pushes the limits until `runtime` is cancelled or the limits stop being published. @@ -123,6 +125,7 @@ mod tests { use httpmock::{Mock, prelude::*}; use super::*; + use crate::test_utils::rpc_client_builder; const LIMITS: DaLimits = DaLimits { max_tx_size: 150, max_block_size: 20_000 }; @@ -155,7 +158,9 @@ mod tests { /// A pusher to `server` that starts from [`LIMITS`], and the sender of its limits. fn pusher(server: &MockServer) -> (ThrottlePusher, watch::Sender) { let (limits_tx, limits_rx) = watch::channel(LIMITS); - let pusher = ThrottlePusher::new(&server.url("/").parse().unwrap(), limits_rx).unwrap(); + let pusher = + ThrottlePusher::new(&server.url("/").parse().unwrap(), limits_rx, rpc_client_builder()) + .unwrap(); (pusher, limits_tx) } @@ -194,8 +199,12 @@ mod tests { let (to_refusing, _refusing_tx) = pusher(&refusing); let (to_failing, _failing_tx) = pusher(&failing); let (_unreachable_tx, unreachable_rx) = watch::channel(LIMITS); - let to_unreachable = - ThrottlePusher::new(&"http://127.0.0.1:1".parse().unwrap(), unreachable_rx).unwrap(); + let to_unreachable = ThrottlePusher::new( + &"http://127.0.0.1:1".parse().unwrap(), + unreachable_rx, + rpc_client_builder(), + ) + .unwrap(); to_refusing.push(LIMITS).await.unwrap(); to_failing.push(LIMITS).await.unwrap(); diff --git a/etc/docs-index.toml b/etc/docs-index.toml index 8f1fc4f723..91cf9bfdce 100644 --- a/etc/docs-index.toml +++ b/etc/docs-index.toml @@ -152,7 +152,7 @@ digest = "d1525977c0" ["crates/batcher/cli/README.md"] summary = "base-batcher-cli: base batcher flags and startup, with sequencer endpoints and leader search, channel and DA config, and shadow mode posting to the inbox a parity validator derives its chain from." -digest = "8c65ed44e2" +digest = "1ccdbc3fb7" ["crates/batcher/comp/README.md"] summary = "base-comp: incremental Brotli channel compression (CompressionStream, BrotliLevel) producing protocol-compatible channels; batch encoding and framing live in batcher-encoder." @@ -160,7 +160,7 @@ digest = "ce9421e523" ["crates/batcher/core/README.md"] summary = "base-batcher-core: async BatchDriver, SubmissionQueue for L1 transactions, and the DA throttle (Off, Step, Linear, Quadratic strategies) that publishes the DA limits the block builders apply." -digest = "0f339286c4" +digest = "de7352c3a8" ["crates/batcher/encoder/README.md"] summary = "base-batcher-encoder: synchronous, no-I/O encoder from L2 blocks to L1 submissions. Covers channel closing rules, blob framing, metric labels, and BatchEncoder usage." From 9301f77741397e91f58db619a84035a221bc07d6 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Thu, 15 Oct 2026 23:24:55 +0200 Subject: [PATCH 03/18] [backport] fix(proof): per-game intermediate block interval and snark-e2e step back to v1.5.1 (#5877) * fix(proof): use each game's intermediate block interval (#5794) * fix(proof): use each game's intermediate block interval * fix(prover-service): reject rows without a request payload instead of rebuilding one (cherry picked from commit d918f45c0bdd4d191184f83fce5ee1acee4fa37e) * fix(snark-e2e): step back 100 L2 blocks at a time for 200ms blocks (#5840) (cherry picked from commit 65a7646de1f18ca46c1d0cb9b22bce34bd435346) --- bin/basectl/README.md | 4 +- crates/infra/basectl/src/commands/proofs.rs | 2 +- crates/infra/basectl/src/rpc/prover.rs | 33 ++-- .../challenger-e2e/src/challenger_e2e.rs | 20 +- .../infra/challenger-e2e/src/mock_prover.rs | 4 + crates/infra/snark-e2e/src/snark_e2e.rs | 10 +- crates/infra/zk-benchmarks/src/runner.rs | 7 +- .../infra/zk-fork-dispute/src/checkpoint.rs | 5 + crates/proof/challenge/src/driver.rs | 1 - crates/proof/challenge/src/pending.rs | 4 +- crates/proof/challenge/src/proof_adapter.rs | 8 +- crates/proof/challenge/src/proof_manager.rs | 12 +- crates/proof/challenge/src/scanner.rs | 14 +- crates/proof/challenge/src/validator.rs | 59 ++---- crates/proof/challenge/tests/driver.rs | 4 +- crates/proof/host/Cargo.toml | 1 + crates/proof/host/src/backend/online.rs | 2 +- crates/proof/host/src/handler.rs | 2 +- crates/proof/host/src/host.rs | 2 +- crates/proof/host/src/kv/boot.rs | 8 +- crates/proof/primitives/Cargo.toml | 1 + crates/proof/primitives/README.md | 2 +- crates/proof/primitives/src/proof.rs | 42 +++- crates/proof/proof/src/boot.rs | 69 ++++--- crates/proof/proof/src/errors.rs | 6 + crates/proof/proof/src/intermediate_roots.rs | 107 +++++++++++ crates/proof/proof/src/lib.rs | 3 + crates/proof/proposer/Cargo.toml | 1 + crates/proof/proposer/src/proof_adapter.rs | 9 +- crates/proof/proposer/src/proof_collector.rs | 19 +- crates/proof/proposer/src/proof_dispatcher.rs | 69 ++++--- crates/proof/proposer/src/proof_recovery.rs | 37 ++-- crates/proof/proposer/src/proof_submitter.rs | 138 ++++++++------ .../proof/proposer/src/proposal_intervals.rs | 12 +- crates/proof/proposer/src/test_utils.rs | 28 ++- .../prover-service/client/src/requester.rs | 2 + .../proof/prover-service/client/src/worker.rs | 2 + crates/proof/prover-service/db/Cargo.toml | 2 +- .../prover-service/db/src/conversions.rs | 5 +- crates/proof/prover-service/db/src/models.rs | 3 + crates/proof/prover-service/db/src/repo.rs | 180 ++---------------- .../db/tests/postgres_integration.rs | 11 +- .../prover-service/protocol/src/types.rs | 84 +++----- .../proof/prover-service/tests/idempotency.rs | 3 + .../prover-service/tests/worker_api_e2e.rs | 3 +- crates/proof/tee/nitro-enclave/src/error.rs | 6 - crates/proof/tee/nitro-enclave/src/server.rs | 138 +++++--------- crates/proof/tee/nitro-host/Cargo.toml | 1 + crates/proof/tee/nitro-host/src/pool.rs | 2 +- .../tee/nitro-host/src/proof_generator.rs | 16 +- .../tee/nitro-host/src/proof_submitter.rs | 9 +- crates/proof/worker/src/job_discovery.rs | 6 +- crates/proof/worker/src/proof_submitter.rs | 2 + .../proof/zk/backend/src/succinct/cluster.rs | 4 +- .../proof/zk/backend/src/succinct/dry_run.rs | 4 +- .../proof/zk/backend/src/succinct/network.rs | 4 +- .../proof/zk/backend/src/succinct/provider.rs | 24 ++- .../zk/backend/src/succinct/utils/stdin.rs | 5 +- crates/proof/zk/host/src/proof_submitter.rs | 2 +- .../programs/succinct/aggregation/src/main.rs | 4 + crates/proof/zk/utils/src/boot.rs | 10 +- crates/proof/zk/utils/src/client.rs | 20 +- crates/proof/zk/utils/src/lib.rs | 1 - crates/proof/zk/utils/src/witness/executor.rs | 9 +- crates/proof/zk/witness/src/fetcher.rs | 3 + crates/proof/zk/witness/src/host.rs | 17 +- etc/docs-index.toml | 4 +- etc/just/zk-prover.just | 17 +- etc/systems/benches/common/zk_proof.rs | 9 +- etc/systems/tests/common/zk_dry_run.rs | 4 +- 70 files changed, 730 insertions(+), 631 deletions(-) create mode 100644 crates/proof/proof/src/intermediate_roots.rs diff --git a/bin/basectl/README.md b/bin/basectl/README.md index 87781b36a6..0e86e69048 100644 --- a/bin/basectl/README.md +++ b/bin/basectl/README.md @@ -432,7 +432,7 @@ because the prover service is internal, so one of the three must be provided. | `--zk-backend ` | ZK proving backend: `network` (default, Succinct Prover Network, paid in PROVE) or `cluster`. Use `proofs propose --zk-backend dry-run` for sizing; dry-run cannot finalize because it produces no proof bytes. | | `--session-id ` | Explicit proof session ID. If omitted, basectl derives one from the network, backend, game, block range, checkpoint stride, and submitter wallet. | | `--retry-failed` | Explicitly allow an existing failed session to be requeued. Without this flag, rerunning leaves failed sessions unchanged. | -| `--intermediate-root-interval ` | Checkpoint stride override. Only needed when the game type has no registered implementation to read `INTERMEDIATE_BLOCK_INTERVAL` from; otherwise the flag must match that canonical value. | +| `--intermediate-root-interval ` | Checkpoint stride override. Only needed when the game does not expose its interval configuration; otherwise the flag must match the game's own interval, which the prover samples the proof's intermediate roots at. | | `--prover-rpc ` | Prover-service RPC URL. Also `BASECTL_PROVER_RPC` or config `prover_rpc`. | | `--factory
` | `DisputeGameFactory` address. Also config `proofs.dispute_game_factory`. | | `--l1-rpc ` | L1 RPC URL. Also config `l1_rpc`. | @@ -487,7 +487,7 @@ submitted from another. | `--zk-backend ` | ZK proving backend: `network` (default, Succinct Prover Network, paid in PROVE), `cluster`, or `dry-run`. | | `--session-id ` | Explicit proof session ID. If omitted, derived from the network name, ZK backend, game address, block range, checkpoint stride, and prover address. | | `--retry-failed` | Explicitly allow an existing failed session to be requeued. Without this flag, rerunning leaves failed sessions unchanged. | -| `--intermediate-root-interval ` | Checkpoint stride override. Only needed when the game type has no registered implementation to read `INTERMEDIATE_BLOCK_INTERVAL` from; otherwise the flag must match that canonical value. | +| `--intermediate-root-interval ` | Checkpoint stride override. Only needed when the game does not expose its interval configuration; otherwise the flag must match the game's own interval, which the prover samples the proof's intermediate roots at. | | `--wait` | Poll the prover service until the proof succeeds or fails. | | `--prover-rpc ` | Prover-service RPC URL. Also `BASECTL_PROVER_RPC` or config `prover_rpc`. | | `--factory
` | `DisputeGameFactory` address. Also config `proofs.dispute_game_factory`. | diff --git a/crates/infra/basectl/src/commands/proofs.rs b/crates/infra/basectl/src/commands/proofs.rs index 376fa57af9..a53d796902 100644 --- a/crates/infra/basectl/src/commands/proofs.rs +++ b/crates/infra/basectl/src/commands/proofs.rs @@ -1027,7 +1027,7 @@ impl ProofsProposeJson { start_block: request.pre_state_block.saturating_add(1), end_block: request.pre_state_block.saturating_add(request.num_blocks), num_blocks: request.num_blocks, - intermediate_root_interval: request.intermediate_root_interval, + intermediate_root_interval: request.intermediate_root_interval.get(), status: ProofOutputStatus::Submitted, error_message: None, result: None, diff --git a/crates/infra/basectl/src/rpc/prover.rs b/crates/infra/basectl/src/rpc/prover.rs index 65c74e8564..2387aee24b 100644 --- a/crates/infra/basectl/src/rpc/prover.rs +++ b/crates/infra/basectl/src/rpc/prover.rs @@ -1,6 +1,6 @@ //! Prover-service requester client helpers for the `basectl proofs` command group. -use std::{fmt, time::Duration}; +use std::{fmt, num::NonZeroU64, time::Duration}; use alloy_primitives::{Address, B256}; use base_prover_service_client::{ @@ -38,7 +38,7 @@ pub struct ProofProposeRequest { /// L1 head hash stored at game creation time. pub l1_head: B256, /// Intermediate output root interval matching the game's checkpoints. - pub intermediate_root_interval: u64, + pub intermediate_root_interval: NonZeroU64, /// L1 wallet address that will later submit the proof on chain. /// /// The proof journal commits to this address as the proposer, so the @@ -133,7 +133,7 @@ impl ProofProposeRequest { fn intermediate_root_interval( details: &GameDetails, intermediate_root_interval: Option, - ) -> Result { + ) -> Result { let not_provable = |reason: &str| ProofsCommandError::GameNotProvable { game: details.address.to_string(), reason: reason.to_string(), @@ -158,17 +158,17 @@ impl ProofProposeRequest { pass --intermediate-root-interval", ) })?; - if intermediate_root_interval == 0 - || !details.block_interval.is_multiple_of(intermediate_root_interval) - { + let Some(intermediate_root_interval) = NonZeroU64::new(intermediate_root_interval) + .filter(|interval| details.block_interval.is_multiple_of(interval.get())) + else { return Err(not_provable(&format!( "intermediate root interval {intermediate_root_interval} must be a nonzero \ divisor of the game's {}-block range", details.block_interval ))); - } + }; - let expected_root_count = details.block_interval / intermediate_root_interval; + let expected_root_count = details.block_interval / intermediate_root_interval.get(); if expected_root_count != details.intermediate_root_count as u64 { return Err(not_provable(&format!( "game committed {} intermediate root(s) but its {}-block range at a \ @@ -209,7 +209,7 @@ impl ProofProposeRequest { game: Address, pre_state_block: u64, num_blocks: u64, - intermediate_root_interval: u64, + intermediate_root_interval: NonZeroU64, prover_address: Address, zk_backend: ZkBackend, ) -> String { @@ -226,7 +226,7 @@ impl ProofProposeRequest { game.as_slice(), &pre_state_block.to_be_bytes(), &num_blocks.to_be_bytes(), - &intermediate_root_interval.to_be_bytes(), + &intermediate_root_interval.get().to_be_bytes(), prover_address.as_slice(), ], ) @@ -245,6 +245,7 @@ impl ProofProposeRequest { sequence_window: None, l1_head: Some(self.l1_head), schedule_l2_block_number: None, + intermediate_block_interval: self.intermediate_root_interval, zk_vm: ZkVm::Sp1, zk_backend: self.zk_backend, }, @@ -435,6 +436,7 @@ mod tests { error::Error as _, io, net::SocketAddr, + num::NonZeroU64, sync::{Arc, Mutex}, time::Duration, }; @@ -498,7 +500,7 @@ mod tests { assert_eq!(request.pre_state_block, 4000); assert_eq!(request.num_blocks, 1000); assert_eq!(request.l1_head, B256::repeat_byte(0x22)); - assert_eq!(request.intermediate_root_interval, 100); + assert_eq!(request.intermediate_root_interval.get(), 100); assert_eq!(request.prover_address, Address::repeat_byte(0xDD)); } @@ -582,7 +584,7 @@ mod tests { ) .expect("stride override should build a request"); - assert_eq!(request.intermediate_root_interval, 250); + assert_eq!(request.intermediate_root_interval.get(), 250); } #[test] @@ -633,8 +635,10 @@ mod tests { ProofProposeRequest { prover_address: Address::repeat_byte(0x88), ..request.clone() }; let other_backend = ProofProposeRequest { zk_backend: ZkBackend::Cluster, ..request.clone() }; - let other_stride = - ProofProposeRequest { intermediate_root_interval: 200, ..request.clone() }; + let other_stride = ProofProposeRequest { + intermediate_root_interval: NonZeroU64::new(200).unwrap(), + ..request.clone() + }; let base_id = request.effective_session_id("mainnet"); assert_ne!(base_id, other_game.effective_session_id("mainnet")); @@ -688,6 +692,7 @@ mod tests { assert_eq!(snark.proof.number_of_blocks_to_prove, 1000); assert_eq!(snark.proof.sequence_window, None); assert_eq!(snark.proof.l1_head, Some(B256::repeat_byte(0x22))); + assert_eq!(snark.proof.intermediate_block_interval.get(), 100); assert_eq!(snark.proof.zk_vm, ZkVm::Sp1); assert_eq!(snark.proof.zk_backend, ZkBackend::Network); } diff --git a/crates/infra/challenger-e2e/src/challenger_e2e.rs b/crates/infra/challenger-e2e/src/challenger_e2e.rs index 19027e740a..bfd9d6a33b 100644 --- a/crates/infra/challenger-e2e/src/challenger_e2e.rs +++ b/crates/infra/challenger-e2e/src/challenger_e2e.rs @@ -2,6 +2,7 @@ use std::{ collections::BTreeSet, + num::NonZeroU64, path::Path, sync::Arc, time::{Duration, Instant}, @@ -1072,8 +1073,12 @@ impl ChallengerE2e { let verifier = AggregateVerifierContractClient::new(provider.clone()); let l1_head = verifier.l1_head(game).await?; let schedule = verifier.game_info(game).await?.l2_block_number; - let expected = - Self::expected_dispute_request(checkpoint, l1_head, schedule, challenger.address()); + let expected = Self::expected_dispute_request( + checkpoint, + l1_head, + schedule, + challenger.address(), + )?; let requested: Vec<_> = requests .snark_requests() .into_iter() @@ -1102,24 +1107,27 @@ impl ChallengerE2e { /// schedule pinned to the game's final L2 block, and the disputer as /// prover. Mirrors what the challenger builds (`build_zk_request`) and /// what the contract reconstructs into the journal. - const fn expected_dispute_request( + fn expected_dispute_request( checkpoint: &Checkpoint, l1_head: B256, schedule_l2_block_number: u64, prover_address: Address, - ) -> SnarkPlonkProofRequest { - SnarkPlonkProofRequest { + ) -> Result { + let intermediate_block_interval = NonZeroU64::new(checkpoint.interval) + .ok_or_else(|| eyre!("checkpoint interval must be nonzero"))?; + Ok(SnarkPlonkProofRequest { proof: ZkProofRequest { start_block_number: checkpoint.start_block, number_of_blocks_to_prove: checkpoint.block_count, sequence_window: None, l1_head: Some(l1_head), schedule_l2_block_number: Some(schedule_l2_block_number), + intermediate_block_interval, zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }, prover_address, - } + }) } /// Path 3: the challenger must clear the invalid ZK-only proposal. diff --git a/crates/infra/challenger-e2e/src/mock_prover.rs b/crates/infra/challenger-e2e/src/mock_prover.rs index 557e891477..b251930748 100644 --- a/crates/infra/challenger-e2e/src/mock_prover.rs +++ b/crates/infra/challenger-e2e/src/mock_prover.rs @@ -281,6 +281,8 @@ impl Drop for MockProver { #[cfg(test)] mod tests { + use std::num::NonZeroU64; + use alloy_primitives::{Address, B256}; use base_proof_primitives::ProofRequest as TeeRequest; use base_proof_submission::SnarkReceiptEncoder; @@ -305,6 +307,7 @@ mod tests { sequence_window: None, l1_head: Some(B256::repeat_byte(0x11)), schedule_l2_block_number: Some(start_block_number + 600), + intermediate_block_interval: NonZeroU64::new(30).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }, @@ -368,6 +371,7 @@ mod tests { proposer: Address::repeat_byte(5), l1_head_number: 1200, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(30).unwrap(), }, tee_kind: TeeKind::AwsNitro, }), diff --git a/crates/infra/snark-e2e/src/snark_e2e.rs b/crates/infra/snark-e2e/src/snark_e2e.rs index b25bc7fa71..e91afd3f9c 100644 --- a/crates/infra/snark-e2e/src/snark_e2e.rs +++ b/crates/infra/snark-e2e/src/snark_e2e.rs @@ -6,6 +6,8 @@ //! Talks to the JSON-RPC prover-service requester API (`prover_proveBlockRange` //! / `prover_getProof`), not the legacy zk gRPC service. +use std::num::NonZeroU64; + use alloy_primitives::Address; use alloy_provider::{Identity, Provider, ProviderBuilder}; use alloy_rpc_types::{BlockId, BlockNumberOrTag}; @@ -35,8 +37,10 @@ const POLL_TIMEOUT_SECS: u64 = 14400; // 4 hours const SEQUENCE_WINDOW: u64 = 100; /// When L1 hasn't finalized far enough for the target L2 block, step back -/// this many L2 blocks at a time and recheck. -const L2_BLOCK_STEP_BACK: u64 = 10; +/// this many L2 blocks at a time and recheck. With `MAX_STEP_BACKS` this +/// reaches 30,000 blocks back, 100 minutes at a 200ms block time, enough to +/// cover L1 finality plus `SEQUENCE_WINDOW` L1 blocks. +const L2_BLOCK_STEP_BACK: u64 = 100; /// Maximum number of times we step back before giving up. const MAX_STEP_BACKS: u64 = 300; @@ -284,6 +288,8 @@ impl SnarkE2e { sequence_window: Some(SEQUENCE_WINDOW), l1_head: None, schedule_l2_block_number: None, + // A single-block range commits its one output root. + intermediate_block_interval: NonZeroU64::MIN, zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }, diff --git a/crates/infra/zk-benchmarks/src/runner.rs b/crates/infra/zk-benchmarks/src/runner.rs index 644a8a6232..e5fa0449d3 100644 --- a/crates/infra/zk-benchmarks/src/runner.rs +++ b/crates/infra/zk-benchmarks/src/runner.rs @@ -1,6 +1,9 @@ //! ZK proof benchmark runner for completed load-test runs. -use std::time::{Duration, Instant}; +use std::{ + num::NonZeroU64, + time::{Duration, Instant}, +}; use alloy_eips::BlockNumberOrTag; use alloy_primitives::B256; @@ -154,6 +157,8 @@ impl ZkBenchRunner { sequence_window: None, l1_head: Some(l1_head), schedule_l2_block_number: None, + // A single-block range commits its one output root. + intermediate_block_interval: NonZeroU64::MIN, zk_vm: ZkVm::Sp1, zk_backend, }), diff --git a/crates/infra/zk-fork-dispute/src/checkpoint.rs b/crates/infra/zk-fork-dispute/src/checkpoint.rs index 64678ae865..41c03c3b62 100644 --- a/crates/infra/zk-fork-dispute/src/checkpoint.rs +++ b/crates/infra/zk-fork-dispute/src/checkpoint.rs @@ -1,5 +1,7 @@ //! Invalid intermediate-root checkpoint selection, Anvil patching, and proving. +use std::num::NonZeroU64; + use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use alloy_provider::{Provider, RootProvider}; use base_challenger::ChallengerProofAdapter; @@ -199,6 +201,8 @@ impl Checkpoint { "requesting SNARK PLONK proof" ); + let intermediate_block_interval = NonZeroU64::new(self.interval) + .ok_or_else(|| eyre!("intermediate root interval must be nonzero"))?; let snark_request = SnarkPlonkProofRequest { proof: ZkProofRequest { start_block_number: self.start_block, @@ -206,6 +210,7 @@ impl Checkpoint { sequence_window: None, l1_head: Some(l1_head), schedule_l2_block_number: Some(game_l2_block_number), + intermediate_block_interval, zk_vm: ZkVm::Sp1, zk_backend: config.zk_backend, }, diff --git a/crates/proof/challenge/src/driver.rs b/crates/proof/challenge/src/driver.rs index 7aac08a046..2e3f191802 100644 --- a/crates/proof/challenge/src/driver.rs +++ b/crates/proof/challenge/src/driver.rs @@ -219,7 +219,6 @@ impl Driver { Ok(None) } ValidatorError::CheckpointCountMismatch { .. } - | ValidatorError::InvalidInterval | ValidatorError::InvalidBlockRange { .. } => Err(e.into()), _ => { warn!( diff --git a/crates/proof/challenge/src/pending.rs b/crates/proof/challenge/src/pending.rs index c623cc5623..e44d7c8583 100644 --- a/crates/proof/challenge/src/pending.rs +++ b/crates/proof/challenge/src/pending.rs @@ -349,6 +349,7 @@ pub enum ProofUpdate { #[cfg(test)] mod tests { use std::{ + num::NonZeroU64, sync::{Arc, Mutex}, time::Duration, }; @@ -367,6 +368,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::MIN, zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }, @@ -512,8 +514,8 @@ mod tests { MockZkProofState { proof_status: ProofStatus::Succeeded, result: Some(ApiProofResult::Tee(TeeProofResult { + intermediate_roots: vec![bad_proposal.output_root], aggregate_proposal: bad_proposal, - proposals: Vec::new(), tee_kind: TeeKind::AwsNitro, tee_signer: Address::repeat_byte(0x11), })), diff --git a/crates/proof/challenge/src/proof_adapter.rs b/crates/proof/challenge/src/proof_adapter.rs index 332f4266a1..3ce239cb9a 100644 --- a/crates/proof/challenge/src/proof_adapter.rs +++ b/crates/proof/challenge/src/proof_adapter.rs @@ -111,6 +111,8 @@ impl ChallengerProofAdapter { #[cfg(test)] mod tests { + use std::num::NonZeroU64; + use alloy_primitives::{Address, B256, Bytes}; use base_proof_primitives::{PROOF_TYPE_TEE, PROOF_TYPE_ZK, ProofRequest, Proposal}; use base_proof_submission::test_utils::SnarkReceiptFixture; @@ -179,6 +181,7 @@ mod tests { sequence_window: Some(10), l1_head: Some(l1_head), schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(300).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }; @@ -208,6 +211,7 @@ mod tests { proposer: Address::repeat_byte(0x04), l1_head_number: 1200, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(30).unwrap(), }; let session_id = ChallengerProofAdapter::tee_session_id(game_address, invalid_index); @@ -244,7 +248,7 @@ mod tests { let root = B256::repeat_byte(0xaa); let result = ProofResult::Tee(TeeProofResult { aggregate_proposal: test_proposal(root), - proposals: Vec::new(), + intermediate_roots: vec![root], tee_kind: TeeKind::AwsNitro, tee_signer: Address::repeat_byte(0x11), }); @@ -259,7 +263,7 @@ mod tests { fn tee_dispute_proof_bytes_rejects_unexpected_root() { let result = ProofResult::Tee(TeeProofResult { aggregate_proposal: test_proposal(B256::repeat_byte(0xaa)), - proposals: Vec::new(), + intermediate_roots: vec![B256::repeat_byte(0xaa)], tee_kind: TeeKind::AwsNitro, tee_signer: Address::repeat_byte(0x11), }); diff --git a/crates/proof/challenge/src/proof_manager.rs b/crates/proof/challenge/src/proof_manager.rs index ce16b065b1..d1ae28f0fd 100644 --- a/crates/proof/challenge/src/proof_manager.rs +++ b/crates/proof/challenge/src/proof_manager.rs @@ -279,9 +279,10 @@ impl DisputeProofManager { l1_provider: &dyn L1Provider, ) -> eyre::Result { let start_block_number = candidate.checkpoint_start_block(invalid_index)?; + let intermediate_block_interval = candidate.intermediate_block_interval; let claimed_l2_block_number = start_block_number - .checked_add(candidate.intermediate_block_interval) + .checked_add(intermediate_block_interval.get()) .ok_or_else(|| eyre::eyre!("claimed_l2_block_number overflow"))?; let l1_head = candidate.l1_head; @@ -301,6 +302,7 @@ impl DisputeProofManager { proposer, l1_head_number, schedule_l2_block_number: Some(candidate.info.l2_block_number), + intermediate_block_interval, }) } @@ -311,14 +313,17 @@ impl DisputeProofManager { invalid_index: u64, ) -> eyre::Result { let start_block_number = candidate.checkpoint_start_block(invalid_index)?; + let intermediate_block_interval = candidate.intermediate_block_interval; + // A dispute proves the one segment ending at the disputed root. Ok(SnarkPlonkProofRequest { proof: ZkProofRequest { start_block_number, - number_of_blocks_to_prove: candidate.intermediate_block_interval, + number_of_blocks_to_prove: intermediate_block_interval.get(), sequence_window: None, l1_head: Some(candidate.l1_head), schedule_l2_block_number: Some(candidate.info.l2_block_number), + intermediate_block_interval, zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }, @@ -624,7 +629,7 @@ impl DisputeProofManager { #[cfg(test)] mod tests { - use std::{collections::HashMap, sync::Arc, time::Duration}; + use std::{collections::HashMap, num::NonZeroU64, sync::Arc, time::Duration}; use alloy_primitives::{Address, B256, Bytes}; use base_proof_contracts::{AggregateVerifierClient, GameStatus, l1_origin_too_old_selector}; @@ -649,6 +654,7 @@ mod tests { sequence_window: None, l1_head: Some(B256::repeat_byte(0xAA)), schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(10).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }, diff --git a/crates/proof/challenge/src/scanner.rs b/crates/proof/challenge/src/scanner.rs index ddea748c90..bab566899b 100644 --- a/crates/proof/challenge/src/scanner.rs +++ b/crates/proof/challenge/src/scanner.rs @@ -32,7 +32,7 @@ //! Games that are not `IN_PROGRESS` or have been fully nullified (both //! provers zero) are skipped. -use std::sync::Arc; +use std::{num::NonZeroU64, sync::Arc}; use alloy_primitives::{Address, B256}; use base_proof_contracts::{ @@ -95,8 +95,8 @@ pub struct CandidateGame { pub info: GameInfo, /// The starting block number for this game. pub starting_block_number: u64, - /// The intermediate block interval for this game's type. - pub intermediate_block_interval: u64, + /// The number of L2 blocks between two of this game's intermediate output roots. + pub intermediate_block_interval: NonZeroU64, /// The L1 head block hash stored at game creation time. pub l1_head: B256, /// Address of the TEE prover for this game (`Address::ZERO` if none registered). @@ -110,6 +110,7 @@ impl CandidateGame { pub fn checkpoint_start_block(&self, index: u64) -> eyre::Result { let offset = self .intermediate_block_interval + .get() .checked_mul(index) .ok_or_else(|| eyre::eyre!("checkpoint offset overflow"))?; self.starting_block_number @@ -397,6 +398,8 @@ impl GameScanner { .verifier_client .read_intervals_for_starting_block(factory.proxy, starting_block_number) .await?; + let intermediate_block_interval = NonZeroU64::new(intermediate_block_interval) + .ok_or_else(|| eyre::eyre!("game {} has a zero intermediate block interval", index))?; Ok(Some(CandidateGame { index, @@ -1081,10 +1084,11 @@ mod tests { assert_eq!(candidates.len(), 2); assert_eq!(candidates[0].starting_block_number, 90); - assert_eq!(candidates[0].intermediate_block_interval, 5, "slow-cadence game"); + assert_eq!(candidates[0].intermediate_block_interval.get(), 5, "slow-cadence game"); assert_eq!(candidates[1].starting_block_number, 390); assert_eq!( - candidates[1].intermediate_block_interval, 2, + candidates[1].intermediate_block_interval.get(), + 2, "fast-cadence game must not inherit the slow-cadence interval" ); } diff --git a/crates/proof/challenge/src/validator.rs b/crates/proof/challenge/src/validator.rs index 569c4c92ca..b40f86898b 100644 --- a/crates/proof/challenge/src/validator.rs +++ b/crates/proof/challenge/src/validator.rs @@ -6,7 +6,7 @@ //! [`OutputRoot`](base_protocol::OutputRoot), and compares them against the //! onchain claims. -use std::sync::Arc; +use std::{num::NonZeroU64, sync::Arc}; use alloy_eips::BlockNumberOrTag; use alloy_primitives::{Address, B256, keccak256}; @@ -43,10 +43,6 @@ pub enum ValidatorError { computed_hash: B256, }, - /// The intermediate block interval is zero, which would cause an infinite loop. - #[error("intermediate block interval must be non-zero")] - InvalidInterval, - /// The block range is degenerate (`starting_block_number >= l2_block_number`). #[error( "invalid block range: starting block {starting_block_number} >= end block {l2_block_number}" @@ -118,7 +114,7 @@ pub struct IntermediateValidationParams<'a> { /// The L2 block number at the end of this game's range. pub l2_block_number: u64, /// The block interval between intermediate output root checkpoints. - pub intermediate_block_interval: u64, + pub intermediate_block_interval: NonZeroU64, /// The final output root claimed by this dispute game. pub claimed_root: B256, /// The intermediate output roots to validate, one per checkpoint. @@ -322,8 +318,7 @@ impl OutputValidator { /// /// # Errors /// - /// Returns [`ValidatorError::InvalidInterval`] if `intermediate_block_interval` - /// is zero, [`ValidatorError::InvalidBlockRange`] if + /// Returns [`ValidatorError::InvalidBlockRange`] if /// `starting_block_number >= l2_block_number` (degenerate block range), /// [`ValidatorError::CheckpointCountMismatch`] if the provided /// `intermediate_roots` length does not match the expected checkpoint count, @@ -341,10 +336,7 @@ impl OutputValidator { claimed_root, intermediate_roots, } = params; - - if intermediate_block_interval == 0 { - return Err(ValidatorError::InvalidInterval); - } + let intermediate_block_interval = intermediate_block_interval.get(); if starting_block_number >= l2_block_number { return Err(ValidatorError::InvalidBlockRange { @@ -401,7 +393,7 @@ impl OutputValidator { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{num::NonZeroU64, sync::Arc}; use alloy_consensus::Header as ConsensusHeader; use alloy_primitives::{Address, B256}; @@ -476,7 +468,7 @@ mod tests { game_address, starting_block_number: 90, l2_block_number: 100, - intermediate_block_interval: 5, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), claimed_root: final_claimed, intermediate_roots: &roots, }) @@ -508,7 +500,7 @@ mod tests { game_address, starting_block_number: 90, l2_block_number: 100, - intermediate_block_interval: 5, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), claimed_root: B256::ZERO, intermediate_roots: &roots, }) @@ -543,7 +535,7 @@ mod tests { game_address, starting_block_number: 90, l2_block_number: 100, - intermediate_block_interval: 5, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), claimed_root: roots[1], intermediate_roots: &intermediate_roots, }) @@ -576,31 +568,6 @@ mod tests { ); } - /// Zero intermediate block interval returns `ValidatorError::InvalidInterval`. - #[tokio::test] - async fn test_zero_intermediate_interval() { - let provider = MockL2Provider::default(); - let validator = OutputValidator::new(Arc::new(provider)); - let game_address = Address::repeat_byte(0x07); - - let result = validator - .validate_intermediate_roots(IntermediateValidationParams { - game_address, - starting_block_number: 90, - l2_block_number: 100, - intermediate_block_interval: 0, - claimed_root: B256::ZERO, - intermediate_roots: &[], - }) - .await; - - assert!(result.is_err()); - assert!( - matches!(result.unwrap_err(), ValidatorError::InvalidInterval), - "expected InvalidInterval" - ); - } - #[rstest] #[case::too_few(vec![B256::ZERO], 2, 1)] #[case::too_many(vec![B256::ZERO; 3], 2, 3)] @@ -619,7 +586,7 @@ mod tests { game_address, starting_block_number: 90, l2_block_number: 100, - intermediate_block_interval: 5, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), claimed_root: B256::ZERO, intermediate_roots: &intermediate_roots, }) @@ -649,7 +616,7 @@ mod tests { game_address, starting_block_number: u64::MAX - 1, l2_block_number: u64::MAX, - intermediate_block_interval: u64::MAX, // Would overflow: (u64::MAX-1) + u64::MAX, but no checkpoints needed + intermediate_block_interval: NonZeroU64::MAX, // Would overflow: (u64::MAX-1) + u64::MAX, but no checkpoints needed claimed_root: B256::ZERO, intermediate_roots: &roots, }) @@ -666,7 +633,7 @@ mod tests { async fn test_no_overflow_when_all_checkpoints_collected() { let starting = u64::MAX - 20; let l2 = u64::MAX; - let interval = 10; + let interval = NonZeroU64::new(10).unwrap(); // Checkpoints: (u64::MAX - 20) + 10 = u64::MAX - 10, then u64::MAX - 10 + 10 = u64::MAX let checkpoint_blocks = [u64::MAX - 10, u64::MAX]; @@ -706,7 +673,7 @@ mod tests { game_address, starting_block_number, l2_block_number, - intermediate_block_interval: 10, + intermediate_block_interval: NonZeroU64::new(10).unwrap(), claimed_root: B256::ZERO, intermediate_roots: &[], }) @@ -749,7 +716,7 @@ mod tests { game_address, starting_block_number: 90, l2_block_number: 100, - intermediate_block_interval: 5, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), claimed_root: B256::ZERO, intermediate_roots: &[root_95, B256::ZERO], }) diff --git a/crates/proof/challenge/tests/driver.rs b/crates/proof/challenge/tests/driver.rs index 5f24c5acad..2d9f60addb 100644 --- a/crates/proof/challenge/tests/driver.rs +++ b/crates/proof/challenge/tests/driver.rs @@ -2,6 +2,7 @@ use std::{ collections::HashMap, + num::NonZeroU64, sync::{Arc, Mutex}, time::Duration, }; @@ -150,6 +151,7 @@ fn default_ready_proof(intent: DisputeIntent) -> PendingProof { sequence_window: None, l1_head: Some(DEFAULT_L1_HEAD), schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }, @@ -185,8 +187,8 @@ fn succeeded_tee_prover(aggregate_proposal: Proposal) -> Arc HostConfig { HostConfig { - request: ProofRequest::default(), + request: ProofRequest::for_tests(600, 30), prover: ProverConfig { l1_eth_url: "http://127.0.0.1:1".to_string(), l2_eth_url: "http://127.0.0.1:1".to_string(), diff --git a/crates/proof/host/src/handler.rs b/crates/proof/host/src/handler.rs index 34d30c6ac4..59e56c98fb 100644 --- a/crates/proof/host/src/handler.rs +++ b/crates/proof/host/src/handler.rs @@ -1459,7 +1459,7 @@ mod tests { fn test_cfg() -> HostConfig { HostConfig { - request: ProofRequest::default(), + request: ProofRequest::for_tests(600, 30), prover: ProverConfig { l1_eth_url: "http://127.0.0.1:1".to_string(), l2_eth_url: "http://127.0.0.1:1".to_string(), diff --git a/crates/proof/host/src/host.rs b/crates/proof/host/src/host.rs index 093c8e5e4a..b2f3d619f7 100644 --- a/crates/proof/host/src/host.rs +++ b/crates/proof/host/src/host.rs @@ -291,7 +291,7 @@ mod tests { fn host_for_chain(l2_chain_id: u64) -> Host { Host::new(HostConfig { - request: ProofRequest::default(), + request: ProofRequest::for_tests(600, 30), prover: ProverConfig { l1_eth_url: "http://127.0.0.1:1".to_string(), l2_eth_url: "http://127.0.0.1:1".to_string(), diff --git a/crates/proof/host/src/kv/boot.rs b/crates/proof/host/src/kv/boot.rs index d996f7fb60..c2382fc880 100644 --- a/crates/proof/host/src/kv/boot.rs +++ b/crates/proof/host/src/kv/boot.rs @@ -1,8 +1,8 @@ use alloy_primitives::B256; use base_proof::{ - INTERMEDIATE_BLOCK_INTERVAL, INTERMEDIATE_BLOCK_INTERVAL_KEY, L1_CONFIG_KEY, L1_HEAD_KEY, - L1_HEAD_NUMBER_KEY, L2_CHAIN_ID_KEY, L2_CLAIM_BLOCK_NUMBER_KEY, L2_CLAIM_KEY, - L2_OUTPUT_ROOT_KEY, L2_ROLLUP_CONFIG_KEY, L2_SCHEDULE_BLOCK_NUMBER_KEY, PROPOSER_KEY, + INTERMEDIATE_BLOCK_INTERVAL_KEY, L1_CONFIG_KEY, L1_HEAD_KEY, L1_HEAD_NUMBER_KEY, + L2_CHAIN_ID_KEY, L2_CLAIM_BLOCK_NUMBER_KEY, L2_CLAIM_KEY, L2_OUTPUT_ROOT_KEY, + L2_ROLLUP_CONFIG_KEY, L2_SCHEDULE_BLOCK_NUMBER_KEY, PROPOSER_KEY, }; use base_proof_preimage::PreimageKey; @@ -42,7 +42,7 @@ impl KeyValueStore for BootKeyValueStore { } PROPOSER_KEY => Some(self.cfg.request.proposer.to_vec()), INTERMEDIATE_BLOCK_INTERVAL_KEY => { - Some(INTERMEDIATE_BLOCK_INTERVAL.to_be_bytes().to_vec()) + Some(self.cfg.request.intermediate_block_interval.get().to_be_bytes().to_vec()) } L1_HEAD_NUMBER_KEY => Some(self.cfg.request.l1_head_number.to_be_bytes().to_vec()), L2_SCHEDULE_BLOCK_NUMBER_KEY => { diff --git a/crates/proof/primitives/Cargo.toml b/crates/proof/primitives/Cargo.toml index e04d66ba7f..cf8a22ffc3 100644 --- a/crates/proof/primitives/Cargo.toml +++ b/crates/proof/primitives/Cargo.toml @@ -60,3 +60,4 @@ rpc-client = [ "serde", "std", ] +test-utils = [] diff --git a/crates/proof/primitives/README.md b/crates/proof/primitives/README.md index 8f2b3463ca..098587c5c0 100644 --- a/crates/proof/primitives/README.md +++ b/crates/proof/primitives/README.md @@ -11,7 +11,7 @@ on-chain verifiers: - **`Proposal`** — An output root proposal with its ECDSA signature and L1/L2 context. - **`ProofBundle`** — Request plus preimage key-value pairs sent to a prover. - **`ProofResult`** — The output of a proof computation, with one variant per backend: - - `Tee` — aggregated and per-block `Proposal`s. + - `Tee` — the signed aggregate `Proposal` and the intermediate output roots it commits to. - `Zk` — opaque proof bytes. ## Usage diff --git a/crates/proof/primitives/src/proof.rs b/crates/proof/primitives/src/proof.rs index 56e5aa6d8e..285a5410cd 100644 --- a/crates/proof/primitives/src/proof.rs +++ b/crates/proof/primitives/src/proof.rs @@ -1,4 +1,5 @@ use alloc::vec::Vec; +use core::num::NonZeroU64; use alloy_primitives::{Address, B256}; use base_proof_preimage::PreimageKey; @@ -9,10 +10,11 @@ use crate::Proposal; /// /// Each variant carries exactly the data its backend needs: /// -/// - **`Tee`**: The TEE server generates a [`Proposal`] for each block in the -/// range, then aggregates them into a single [`Proposal`]. On-chain -/// verification uses the per-proposal ECDSA signatures directly; the -/// attestation document is handled separately at signer registration time. +/// - **`Tee`**: The TEE server signs one [`Proposal`] covering the whole range, +/// committing to the intermediate output roots it sampled. On-chain +/// verification checks that ECDSA signature against the journal it rebuilds +/// from the game; the attestation document is handled separately at signer +/// registration time. /// /// - **`Zk`**: The ZK prover produces an opaque proof blob that the on-chain /// verifier checks against a committed image ID. @@ -22,10 +24,10 @@ use crate::Proposal; pub enum ProofResult { /// Result from a TEE backend. Tee { - /// The aggregated proposal covering the entire proven block range. + /// The signed proposal covering the entire proven block range. aggregate_proposal: Proposal, - /// The individual per-block proposals that were aggregated. - proposals: Vec, + /// The intermediate output roots committed by the aggregate signature. + intermediate_roots: Vec, /// Stamped by the enclave at signing time, so the signer and the proof /// are returned atomically. tee_signer: Address, @@ -41,7 +43,7 @@ pub enum ProofResult { /// /// Maps 1:1 to `BootInfo` local preimage keys on the guest side. /// Changes every proof; the caller passes one per `prove_block()` call. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ProofRequest { /// Hash of the L1 head block. @@ -69,10 +71,32 @@ pub struct ProofRequest { /// L2 block used to pin the upgrade schedule; defaults to the claimed block. #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] pub schedule_l2_block_number: Option, + /// Number of L2 blocks between two intermediate output roots of the game being proven. + pub intermediate_block_interval: NonZeroU64, +} + +#[cfg(any(test, feature = "test-utils"))] +impl ProofRequest { + /// Builds a zeroed request for a game whose range ends at `claimed_l2_block_number`, sampled + /// every `intermediate_block_interval` blocks. + pub const fn for_tests(claimed_l2_block_number: u64, intermediate_block_interval: u64) -> Self { + Self { + l1_head: B256::ZERO, + agreed_l2_head_hash: B256::ZERO, + agreed_l2_output_root: B256::ZERO, + claimed_l2_output_root: B256::ZERO, + claimed_l2_block_number, + proposer: Address::ZERO, + l1_head_number: 0, + schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(intermediate_block_interval) + .expect("intermediate block interval must be non-zero"), + } + } } /// A proof request bundled with the witness data needed to fulfill it. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ProofBundle { /// What to prove. diff --git a/crates/proof/proof/src/boot.rs b/crates/proof/proof/src/boot.rs index 2ca97f18c6..2b17a5d10d 100644 --- a/crates/proof/proof/src/boot.rs +++ b/crates/proof/proof/src/boot.rs @@ -2,6 +2,7 @@ //! through the `PreimageOracle` ABI as local keys. use alloc::vec::Vec; +use core::num::NonZeroU64; use alloy_genesis::ChainConfig; use alloy_primitives::{Address, B256, U256, uint}; @@ -68,16 +69,10 @@ pub const L1_CONFIG_KEY: U256 = uint!(7_U256); /// so on-chain verification can match it against the actual `msg.sender`. pub const PROPOSER_KEY: U256 = uint!(8_U256); -/// Number of L2 blocks between intermediate output root checkpoints. -/// -/// The host writes this into [`INTERMEDIATE_BLOCK_INTERVAL_KEY`]. Callers do not -/// supply it on a proof request. -pub const INTERMEDIATE_BLOCK_INTERVAL: u64 = 300; - /// The local key identifier for the intermediate block interval. /// -/// This key retrieves [`INTERMEDIATE_BLOCK_INTERVAL`]. The enclave uses it to -/// sample intermediate roots when constructing the aggregate proof journal. +/// This key retrieves the proven game's number of L2 blocks between two intermediate output roots, +/// as a non-zero big-endian `u64`. The proof programs sample the roots they commit at this interval. pub const INTERMEDIATE_BLOCK_INTERVAL_KEY: U256 = uint!(9_U256); /// The local key identifier for the L1 head block number. @@ -183,12 +178,11 @@ pub struct BootInfo { /// transaction sender matches this value. #[serde(default)] pub proposer: Address, - /// The number of L2 blocks between intermediate output root checkpoints. + /// The number of L2 blocks between two intermediate output roots of the proven game. /// - /// Used by the enclave to sample the correct intermediate roots when - /// constructing the aggregate proof journal. Defaults to 0 when not set. - #[serde(default)] - pub intermediate_block_interval: u64, + /// **Security**: User-submitted input; the on-chain contract rebuilds the journal from the + /// game's own intermediate roots, so a proof sampled at another interval does not verify. + pub intermediate_block_interval: NonZeroU64, /// The block number of the L1 head. /// /// Stored alongside `l1_head` so the enclave can reference the L1 head @@ -382,20 +376,17 @@ impl BootInfo { } }; - // Load intermediate block interval (optional — defaults to 0 for backwards compatibility). let intermediate_block_interval = - match Self::get_optional_local(oracle, INTERMEDIATE_BLOCK_INTERVAL_KEY).await? { - Some(bytes) => u64::from_be_bytes( - bytes.as_slice().try_into().map_err(OracleProviderError::SliceConversion)?, - ), - None => { - debug!( - target: "boot_loader", - "Intermediate block interval preimage not found, defaulting to 0" - ); - 0 - } - }; + Self::get_optional_local(oracle, INTERMEDIATE_BLOCK_INTERVAL_KEY) + .await? + .ok_or(OracleProviderError::MissingIntermediateBlockInterval)?; + let intermediate_block_interval = NonZeroU64::new(u64::from_be_bytes( + intermediate_block_interval + .as_slice() + .try_into() + .map_err(OracleProviderError::SliceConversion)?, + )) + .ok_or(OracleProviderError::ZeroIntermediateBlockInterval)?; // Load L1 head block number (optional — defaults to 0 for backwards compatibility). let l1_head_number = match Self::get_optional_local(oracle, L1_HEAD_NUMBER_KEY).await? { @@ -525,14 +516,28 @@ mod tests { } impl MockOracle { + /// Builds an oracle serving only an intermediate block interval, the one required key no + /// test derives from its chain configuration. fn new() -> Self { - Self { data: Vec::new(), timeout_keys: Vec::new(), miss_returns_invalid_key: false } + let mut oracle = Self { + data: Vec::new(), + timeout_keys: Vec::new(), + miss_returns_invalid_key: false, + }; + oracle.insert(INTERMEDIATE_BLOCK_INTERVAL_KEY, 30u64.to_be_bytes().to_vec()); + oracle } fn insert(&mut self, key: U256, value: Vec) { + self.remove(key); self.data.push((PreimageKey::new_local(key.to()), value)); } + fn remove(&mut self, key: U256) { + let key = PreimageKey::new_local(key.to()); + self.data.retain(|(entry_key, _)| *entry_key != key); + } + fn fail_with_timeout(&mut self, key: U256) { self.timeout_keys.push(PreimageKey::new_local(key.to())); } @@ -1194,9 +1199,16 @@ mod tests { let boot_info = BootInfo::load(&oracle).await.expect("boot info should load"); assert_eq!(boot_info.proposer, Address::ZERO); - assert_eq!(boot_info.intermediate_block_interval, 0); assert_eq!(boot_info.l1_head_number, 0); assert_eq!(boot_info.claimed_l2_block_number, 100); + + let mut oracle = oracle_with_required_keys(); + oracle.remove(INTERMEDIATE_BLOCK_INTERVAL_KEY); + let err = BootInfo::load(&oracle).await.expect_err("missing interval should fail"); + assert!(matches!(err, OracleProviderError::MissingIntermediateBlockInterval)); + oracle.insert(INTERMEDIATE_BLOCK_INTERVAL_KEY, 0u64.to_be_bytes().to_vec()); + let err = BootInfo::load(&oracle).await.expect_err("zero interval should fail"); + assert!(matches!(err, OracleProviderError::ZeroIntermediateBlockInterval)); } #[tokio::test] @@ -1230,7 +1242,6 @@ mod tests { let boot_info = BootInfo::load(&oracle).await.expect("boot info should load"); assert_eq!(boot_info.proposer, Address::ZERO); - assert_eq!(boot_info.intermediate_block_interval, 0); assert_eq!(boot_info.l1_head_number, 0); assert_eq!(boot_info.claimed_l2_block_number, 100); } diff --git a/crates/proof/proof/src/errors.rs b/crates/proof/proof/src/errors.rs index 3c0eb332c7..834a78d172 100644 --- a/crates/proof/proof/src/errors.rs +++ b/crates/proof/proof/src/errors.rs @@ -141,6 +141,12 @@ pub enum OracleProviderError { /// The configured rollup genesis L2 block number. genesis_block: u64, }, + /// The boot data does not carry the game's intermediate block interval, which has no default. + #[error("Missing intermediate block interval")] + MissingIntermediateBlockInterval, + /// The boot data carries a zero intermediate block interval. + #[error("Intermediate block interval must be non-zero")] + ZeroIntermediateBlockInterval, /// The rollup config has a zero L2 block time. #[error("L2 block time must be non-zero")] InvalidL2BlockTime, diff --git a/crates/proof/proof/src/intermediate_roots.rs b/crates/proof/proof/src/intermediate_roots.rs new file mode 100644 index 0000000000..c762c7960a --- /dev/null +++ b/crates/proof/proof/src/intermediate_roots.rs @@ -0,0 +1,107 @@ +//! Sampling of the intermediate output roots committed by an aggregate proof. + +use alloc::vec::Vec; +use core::num::NonZeroU64; + +use alloy_primitives::B256; +use thiserror::Error; + +/// Error returned by [`IntermediateRootSampler::finish`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum IntermediateRootSamplerError { + /// No block was derived, so there is no root to commit. + #[error("cannot sample intermediate roots from an empty block range")] + EmptyRange, + /// The derived range does not end on an intermediate checkpoint. + #[error( + "{blocks} derived block(s) is not a multiple of the intermediate block interval {interval}" + )] + PartialInterval { + /// Number of blocks fed to the sampler. + blocks: u64, + /// Number of blocks between two intermediate roots. + interval: u64, + }, +} + +/// Samples the intermediate output roots of a proven block range. +/// +/// Fed the output root of every derived block in order, it keeps the root of every +/// `interval`-th block. The range must span a non-zero multiple of the interval so that the last +/// sampled root is the range's final root, which the `AggregateVerifier` requires to be the claim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntermediateRootSampler { + interval: NonZeroU64, + blocks: u64, + roots: Vec, +} + +impl IntermediateRootSampler { + /// Creates a sampler that keeps the root of every `interval`-th block. + pub const fn new(interval: NonZeroU64) -> Self { + Self { interval, blocks: 0, roots: Vec::new() } + } + + /// Records the output root of the next derived block. + pub fn push(&mut self, output_root: B256) { + self.blocks += 1; + if self.blocks.is_multiple_of(self.interval.get()) { + self.roots.push(output_root); + } + } + + /// Returns the sampled roots, or an error if the range is empty or does not end on an + /// intermediate checkpoint. + pub fn finish(self) -> Result, IntermediateRootSamplerError> { + if self.blocks == 0 { + return Err(IntermediateRootSamplerError::EmptyRange); + } + if !self.blocks.is_multiple_of(self.interval.get()) { + return Err(IntermediateRootSamplerError::PartialInterval { + blocks: self.blocks, + interval: self.interval.get(), + }); + } + Ok(self.roots) + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + fn root(block: u64) -> B256 { + B256::left_padding_from(&block.to_be_bytes()) + } + + #[rstest] + #[case::pre_denim_game(600, 30, Ok(20))] + #[case::post_denim_game(6000, 300, Ok(20))] + #[case::challenge_segment(30, 30, Ok(1))] + #[case::non_dividing_interval( + 6000, + 7, + Err(IntermediateRootSamplerError::PartialInterval { blocks: 6000, interval: 7 }) + )] + #[case::empty_range(0, 30, Err(IntermediateRootSamplerError::EmptyRange))] + fn samples_every_interval_block( + #[case] blocks: u64, + #[case] interval: u64, + #[case] expected: Result, + ) { + let mut sampler = IntermediateRootSampler::new(NonZeroU64::new(interval).unwrap()); + for block in 1..=blocks { + sampler.push(root(block)); + } + + let expected = + expected.map(|count| (1..=count).map(|i| root(i * interval)).collect::>()); + let roots = sampler.finish(); + assert_eq!(roots, expected); + if let Ok(roots) = roots { + assert_eq!(roots.last(), Some(&root(blocks))); + } + } +} diff --git a/crates/proof/proof/src/lib.rs b/crates/proof/proof/src/lib.rs index de82a3c6f7..f483218d64 100644 --- a/crates/proof/proof/src/lib.rs +++ b/crates/proof/proof/src/lib.rs @@ -31,6 +31,9 @@ pub use hint::{Hint, HintType}; mod boot; pub use boot::*; +mod intermediate_roots; +pub use intermediate_roots::{IntermediateRootSampler, IntermediateRootSamplerError}; + mod caching_oracle; pub use caching_oracle::CachingOracle; diff --git a/crates/proof/proposer/Cargo.toml b/crates/proof/proposer/Cargo.toml index 835433647a..a40d5f20c5 100644 --- a/crates/proof/proposer/Cargo.toml +++ b/crates/proof/proposer/Cargo.toml @@ -80,6 +80,7 @@ alloy-rpc-types-eth.workspace = true tokio = { workspace = true, features = ["test-util"] } metrics-util = { workspace = true, features = ["debugging"] } base-common-genesis = { workspace = true, features = ["serde"] } +base-proof-primitives = { workspace = true, features = ["test-utils"] } [features] metrics = [ "base-metrics/metrics", "base-tx-manager/metrics", "dep:metrics" ] diff --git a/crates/proof/proposer/src/proof_adapter.rs b/crates/proof/proposer/src/proof_adapter.rs index 2030561ee2..460bf5152e 100644 --- a/crates/proof/proposer/src/proof_adapter.rs +++ b/crates/proof/proposer/src/proof_adapter.rs @@ -74,6 +74,8 @@ impl ProposerProofAdapter { #[cfg(test)] mod tests { + use std::num::NonZeroU64; + use alloy_primitives::{Address, B256, Bytes}; use base_prover_service_protocol::{ ProofRequestKind, ProofResult, SnarkPlonkProofResult, TeeKind, TeeProofResult, @@ -93,6 +95,7 @@ mod tests { proposer: Address::repeat_byte(0x04), l1_head_number: 1200, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(30).unwrap(), } } @@ -117,10 +120,10 @@ mod tests { #[test] fn tee_proof_result_converts_to_proposal_parts() { let aggregate = test_proposal(600); - let proposal = test_proposal(300); + let intermediate_roots = vec![test_proposal(300).output_root, aggregate.output_root]; let result = ProofResult::Tee(TeeProofResult { aggregate_proposal: aggregate.clone(), - proposals: vec![proposal.clone()], + intermediate_roots: intermediate_roots.clone(), tee_kind: TeeKind::AwsNitro, tee_signer: Address::repeat_byte(0x11), }); @@ -128,7 +131,7 @@ mod tests { let converted = ProposerProofAdapter::tee_proof_result(result).unwrap(); assert_eq!(converted.aggregate_proposal, aggregate); - assert_eq!(converted.proposals, vec![proposal]); + assert_eq!(converted.intermediate_roots, intermediate_roots); assert_eq!(converted.tee_signer, Address::repeat_byte(0x11)); } diff --git a/crates/proof/proposer/src/proof_collector.rs b/crates/proof/proposer/src/proof_collector.rs index f092c5b74d..84aa302ece 100644 --- a/crates/proof/proposer/src/proof_collector.rs +++ b/crates/proof/proposer/src/proof_collector.rs @@ -306,7 +306,7 @@ where .run_until_cancelled(async { let submit = self.submitter.submit( &proof.aggregate_proposal, - &proof.proposals, + &proof.intermediate_roots, starting_block, target_block, parent_address, @@ -470,7 +470,8 @@ mod tests { DriverConfig, OutputProposer, test_utils::{ MockAggregateVerifier, MockDisputeGameFactory, MockOutputProposer, MockProofRequester, - MockRollupClient, test_fixed_interval_resolver, test_proposal, test_sync_status, + MockRollupClient, test_fixed_interval_resolver, test_proof_request, test_proposal, + test_sync_status, }, }; @@ -575,7 +576,7 @@ mod tests { claimed_l2_output_root: claimed_root, claimed_l2_block_number: target_block, l1_head_number: 1000, - ..Default::default() + ..test_proof_request() }; requester .prove_block_range(ProposerProofAdapter::tee_prove_block_range_request(proof_request)) @@ -612,7 +613,7 @@ mod tests { claimed_l2_output_root: claimed_root, claimed_l2_block_number: target_block, l1_head_number: 1000, - ..Default::default() + ..test_proof_request() }; requester .prove_block_range(ProposerProofAdapter::tee_prove_block_range_request( @@ -675,7 +676,7 @@ mod tests { claimed_l2_output_root: claimed_root, claimed_l2_block_number: target_block, l1_head_number: 1000, - ..Default::default() + ..test_proof_request() }; requester .prove_block_range(ProposerProofAdapter::tee_prove_block_range_request(proof_request)) @@ -688,7 +689,7 @@ mod tests { claimed_l2_output_root: later_root, claimed_l2_block_number: target_block + BLOCK_INTERVAL, l1_head_number: 1000, - ..Default::default() + ..test_proof_request() })) .await .expect("test setup should dispatch later root session"); @@ -724,7 +725,7 @@ mod tests { claimed_l2_output_root: claimed_root, claimed_l2_block_number: target_block, l1_head_number: 1000, - ..Default::default() + ..test_proof_request() }; requester .prove_block_range(ProposerProofAdapter::tee_prove_block_range_request(proof_request)) @@ -758,7 +759,7 @@ mod tests { claimed_l2_output_root: stale_root, claimed_l2_block_number: target_block, l1_head_number: 1000, - ..Default::default() + ..test_proof_request() }; requester .prove_block_range(ProposerProofAdapter::tee_prove_block_range_request(proof_request)) @@ -779,7 +780,7 @@ mod tests { &session_id, TeeProofResult { aggregate_proposal, - proposals: vec![], + intermediate_roots: vec![], tee_kind: TeeKind::AwsNitro, tee_signer: Address::repeat_byte(0x11), }, diff --git a/crates/proof/proposer/src/proof_dispatcher.rs b/crates/proof/proposer/src/proof_dispatcher.rs index 638f921d8e..698dca6576 100644 --- a/crates/proof/proposer/src/proof_dispatcher.rs +++ b/crates/proof/proposer/src/proof_dispatcher.rs @@ -11,8 +11,12 @@ use base_prover_service_client::ProofRequesterProvider; use tracing::{debug, info, instrument, warn}; use crate::{ - Metrics, driver::RecoveredState, error::ProposerError, proof_adapter::ProposerProofAdapter, - proof_target::ProofTarget, proposal_intervals::IntervalResolver, + Metrics, + driver::RecoveredState, + error::ProposerError, + proof_adapter::ProposerProofAdapter, + proof_target::ProofTarget, + proposal_intervals::{IntervalResolver, Intervals}, }; /// Builds and dispatches proposer TEE proof requests. @@ -46,12 +50,14 @@ impl ProofDispatcher { Self { proof_requester, l1_client, l2_client, rollup_client, intervals, proposer_address } } - /// Builds a proof request for `target_block` using `recovered` as the agreed parent. + /// Builds a proof request for `target_block` using `recovered` as the agreed parent, for + /// a game created with `intervals`. async fn build_request( &self, target_block: u64, recovered: &RecoveredState, claimed_l2_output_root: B256, + intervals: Intervals, ) -> Result { let (sync_status, agreed_l2_head) = tokio::try_join!( self.rollup_client.sync_status(), @@ -88,6 +94,7 @@ impl ProofDispatcher { proposer: self.proposer_address, l1_head_number: l1_header.number, schedule_l2_block_number: None, + intermediate_block_interval: intervals.intermediate_block_interval, }) } @@ -176,20 +183,22 @@ impl ProofDispatcher { break; }; - let request = - match self.build_request(target_block, current, claimed_l2_output_root).await { - Ok(request) => request, - Err(error) => { - Metrics::proof_dispatch_total(Metrics::DISPATCH_OUTCOME_BUILD_FAILED) - .increment(1); - warn!( - target_block, - error = %error, - "Failed to build proof request, will retry next iteration" - ); - break; - } - }; + let request = match self + .build_request(target_block, current, claimed_l2_output_root, intervals) + .await + { + Ok(request) => request, + Err(error) => { + Metrics::proof_dispatch_total(Metrics::DISPATCH_OUTCOME_BUILD_FAILED) + .increment(1); + warn!( + target_block, + error = %error, + "Failed to build proof request, will retry next iteration" + ); + break; + } + }; match self.dispatch_request(request).await { Ok(session_id) => { @@ -221,14 +230,14 @@ impl ProofDispatcher { #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::{collections::HashMap, num::NonZeroU64}; use alloy_primitives::Address; use super::*; use crate::test_utils::{ MockL1, MockL2, MockProofRequester, MockRollupClient, test_fixed_interval_resolver, - test_l1_block_ref, test_l1_header, test_l2_block_ref, test_sync_status, + test_l1_block_ref, test_l1_header, test_l2_block_ref, test_proof_request, test_sync_status, }; fn dispatcher(requester: Arc) -> ProofDispatcher { @@ -310,7 +319,15 @@ mod tests { }; let err = dispatcher - .build_request(200, &recovered, B256::repeat_byte(0xaa)) + .build_request( + 200, + &recovered, + B256::repeat_byte(0xaa), + Intervals { + block_interval: 100, + intermediate_block_interval: NonZeroU64::new(100).unwrap(), + }, + ) .await .expect_err("L1 RPC header must match rollup-selected L1 head"); @@ -323,8 +340,10 @@ mod tests { Arc::new(MockProofRequester { return_wrong_session_id: true, ..Default::default() }); let dispatcher = dispatcher(requester); - let request = - ProofRequest { claimed_l2_output_root: B256::repeat_byte(0xaa), ..Default::default() }; + let request = ProofRequest { + claimed_l2_output_root: B256::repeat_byte(0xaa), + ..test_proof_request() + }; let error = dispatcher.dispatch_request(request).await.expect_err("dispatch should fail"); let ProposerError::Prover(message) = error else { @@ -339,8 +358,10 @@ mod tests { Arc::new(MockProofRequester { reject_l1_head_conflict: true, ..Default::default() }); let dispatcher = dispatcher(requester); - let request = - ProofRequest { claimed_l2_output_root: B256::repeat_byte(0xaa), ..Default::default() }; + let request = ProofRequest { + claimed_l2_output_root: B256::repeat_byte(0xaa), + ..test_proof_request() + }; dispatcher.dispatch_request(request).await.expect("dispatch should accept"); } diff --git a/crates/proof/proposer/src/proof_recovery.rs b/crates/proof/proposer/src/proof_recovery.rs index f142c33510..6dabc581f0 100644 --- a/crates/proof/proposer/src/proof_recovery.rs +++ b/crates/proof/proposer/src/proof_recovery.rs @@ -261,7 +261,7 @@ impl ProofRecovery { state.l2_block_number, state.parent_address, intervals.block_interval, - intervals.intermediate_block_interval, + intervals.intermediate_block_interval.get(), &intermediate_roots, ) .map_err(|e| ProposerError::Config(e.to_string()))?; @@ -308,7 +308,7 @@ impl ProofRecovery { #[cfg(test)] mod tests { - use std::{collections::HashMap, sync::Arc}; + use std::{collections::HashMap, num::NonZeroU64, sync::Arc}; use alloy_primitives::{Address, B256}; use base_proof_contracts::AnchorRoot; @@ -348,15 +348,18 @@ mod tests { let block = anchor_block + block_interval * (i as u64 + 1); let root_claim = B256::repeat_byte((i as u8) + 1); let parent_block = block - block_interval; - let intermediate_roots = Intervals { block_interval, intermediate_block_interval } - .intermediate_block_numbers(parent_block) - .unwrap() - .into_iter() - .map(|intermediate_block| match intermediate_block { - n if n == block => root_claim, - n => B256::repeat_byte(n as u8), - }) - .collect::>(); + let intermediate_roots = Intervals { + block_interval, + intermediate_block_interval: NonZeroU64::new(intermediate_block_interval).unwrap(), + } + .intermediate_block_numbers(parent_block) + .unwrap() + .into_iter() + .map(|intermediate_block| match intermediate_block { + n if n == block => root_claim, + n => B256::repeat_byte(n as u8), + }) + .collect::>(); output_roots.insert(block, root_claim); let key = game_lookup_key( parent_block, @@ -521,10 +524,10 @@ mod tests { #[tokio::test] async fn test_recovery_forward_walk_crosses_fast_activation_block() { const SLOW_INTERVAL: u64 = 100; - const SLOW_INTERMEDIATE: u64 = 50; + const SLOW_INTERMEDIATE: NonZeroU64 = NonZeroU64::new(50).unwrap(); const FAST_ACTIVATION_BLOCK: u64 = 300; const FAST_INTERVAL: u64 = 200; - const FAST_INTERMEDIATE: u64 = 100; + const FAST_INTERMEDIATE: NonZeroU64 = NonZeroU64::new(100).unwrap(); // Starting blocks either side of the boundary: 0/100/200 are slow-cadence, // 300/500 are fast-cadence. @@ -560,7 +563,7 @@ mod tests { starting_block, parent, intervals.block_interval, - intervals.intermediate_block_interval, + intervals.intermediate_block_interval.get(), &intermediate_roots, ) .unwrap(); @@ -589,10 +592,10 @@ mod tests { let denim_aware = fixture(test_interval_resolver(MockAggregateVerifier { block_interval: SLOW_INTERVAL, - intermediate_block_interval: SLOW_INTERMEDIATE, + intermediate_block_interval: SLOW_INTERMEDIATE.get(), fast_activation_block: FAST_ACTIVATION_BLOCK, fast_block_interval: FAST_INTERVAL, - fast_intermediate_block_interval: FAST_INTERMEDIATE, + fast_intermediate_block_interval: FAST_INTERMEDIATE.get(), ..Default::default() })); let (state, _) = recover_uncached(&denim_aware).await; @@ -601,7 +604,7 @@ mod tests { // The slow-cadence pair applied everywhere is the bug this guards against: // the walk looks for a 100-block game at 400 and stops at the boundary. - let stale = fixture(test_fixed_interval_resolver(SLOW_INTERVAL, SLOW_INTERMEDIATE)); + let stale = fixture(test_fixed_interval_resolver(SLOW_INTERVAL, SLOW_INTERMEDIATE.get())); let (state, _) = recover_uncached(&stale).await; assert_eq!(state.l2_block_number, FAST_ACTIVATION_BLOCK); } diff --git a/crates/proof/proposer/src/proof_submitter.rs b/crates/proof/proposer/src/proof_submitter.rs index cf21499f85..96a63ca982 100644 --- a/crates/proof/proposer/src/proof_submitter.rs +++ b/crates/proof/proposer/src/proof_submitter.rs @@ -101,7 +101,7 @@ impl ProofSubmitter { pub async fn submit( &self, aggregate_proposal: &Proposal, - proposals: &[Proposal], + intermediate_roots: &[B256], starting_block_number: u64, target_block: u64, parent_address: Address, @@ -138,16 +138,17 @@ impl ProofSubmitter { )))); } - // Extract intermediate roots. + // The game commits the signed roots as is, one per intermediate checkpoint. let intermediate_blocks = intervals .intermediate_block_numbers(starting_block_number) .map_err(SubmitAction::Failed)?; - let intermediate_roots = Self::extract_intermediate_roots( - starting_block_number, - proposals, - &intermediate_blocks, - ) - .map_err(SubmitAction::Failed)?; + if intermediate_roots.len() != intermediate_blocks.len() { + return Err(SubmitAction::Discard(ProposerError::Internal(format!( + "proof signed {} intermediate root(s), the game needs {}", + intermediate_roots.len(), + intermediate_blocks.len() + )))); + } let rollup_client = Arc::clone(&self.rollup_client); let target_root = canonical_output.output_root; @@ -194,8 +195,8 @@ impl ProofSubmitter { starting_block_number, parent_address, intervals.block_interval, - intervals.intermediate_block_interval, - &intermediate_roots, + intervals.intermediate_block_interval.get(), + intermediate_roots, ) .map_err(|e| SubmitAction::Failed(ProposerError::Config(e.to_string())))?; let existing_game = self @@ -219,14 +220,13 @@ impl ProofSubmitter { output_root = ?aggregate_proposal.output_root, parent_address = %parent_address, intermediate_roots_count = intermediate_roots.len(), - proposals_count = proposals.len(), "Proposing output (creating dispute game)" ); let mut propose_timer = base_metrics::timed!(Metrics::proposal_l1_tx_duration_seconds()); let propose_result = self .output_proposer - .propose_output(aggregate_proposal, parent_address, &intermediate_roots) + .propose_output(aggregate_proposal, parent_address, intermediate_roots) .await; match propose_result { @@ -404,32 +404,6 @@ impl ProofSubmitter { SubmitAction::Failed(error) } - - /// Extracts intermediate output roots from per-block proposals. - /// - /// Samples at every `intermediate_block_interval` within the range. - fn extract_intermediate_roots( - starting_block_number: u64, - proposals: &[Proposal], - blocks: &[u64], - ) -> Result, ProposerError> { - blocks - .iter() - .map(|&target_block| { - let idx = target_block.checked_sub(starting_block_number + 1).ok_or_else(|| { - ProposerError::Internal(format!( - "underflow computing proposal index for block {target_block}" - )) - })?; - proposals.get(idx as usize).map(|proposal| proposal.output_root).ok_or_else(|| { - ProposerError::Internal(format!( - "intermediate root at block {target_block} not found in proposals (index {idx}, len {})", - proposals.len() - )) - }) - }) - .collect() - } } #[cfg(test)] @@ -451,11 +425,12 @@ mod tests { const TEST_GAME_TYPE: u32 = 42; const TEST_BLOCK_INTERVAL: u64 = 100; - fn proof_result(target_block: u64) -> (Proposal, Vec) { + /// A proof for a game whose only intermediate checkpoint is its target block. + fn proof_result(target_block: u64) -> (Proposal, Vec) { let mut aggregate_proposal = test_proposal(target_block); aggregate_proposal.l1_origin_hash = B256::ZERO; - let proposals: Vec = (1..=target_block).map(test_proposal).collect(); - (aggregate_proposal, proposals) + let intermediate_roots = vec![aggregate_proposal.output_root]; + (aggregate_proposal, intermediate_roots) } fn submitter( @@ -487,9 +462,9 @@ mod tests { MockAggregateVerifier::default(), ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, intermediate_roots) = proof_result(TEST_BLOCK_INTERVAL); let result = submitter - .submit(&aggregate_proposal, &proposals, 0, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit(&aggregate_proposal, &intermediate_roots, 0, TEST_BLOCK_INTERVAL, Address::ZERO) .await; assert!(result.is_ok()); @@ -516,9 +491,15 @@ mod tests { MockAggregateVerifier::default(), ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, intermediate_roots) = proof_result(TEST_BLOCK_INTERVAL); submitter - .submit(&aggregate_proposal, &proposals, 0, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit( + &aggregate_proposal, + &intermediate_roots, + 0, + TEST_BLOCK_INTERVAL, + Address::ZERO, + ) .await .unwrap(); }); @@ -553,9 +534,11 @@ mod tests { }, ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, _) = proof_result(TEST_BLOCK_INTERVAL); + let intermediate_roots: Vec = + [25, 50, 75, 100].map(|block| test_proposal(block).output_root).to_vec(); let result = submitter - .submit(&aggregate_proposal, &proposals, 0, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit(&aggregate_proposal, &intermediate_roots, 0, TEST_BLOCK_INTERVAL, Address::ZERO) .await; assert!(result.is_ok()); @@ -573,9 +556,9 @@ mod tests { MockAggregateVerifier::default(), ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, intermediate_roots) = proof_result(TEST_BLOCK_INTERVAL); let result = submitter - .submit(&aggregate_proposal, &proposals, 0, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit(&aggregate_proposal, &intermediate_roots, 0, TEST_BLOCK_INTERVAL, Address::ZERO) .await; assert!(result.is_ok()); @@ -597,9 +580,9 @@ mod tests { MockAggregateVerifier::default(), ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, intermediate_roots) = proof_result(TEST_BLOCK_INTERVAL); let result = submitter - .submit(&aggregate_proposal, &proposals, 0, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit(&aggregate_proposal, &intermediate_roots, 0, TEST_BLOCK_INTERVAL, Address::ZERO) .await; let recovered = result.unwrap(); @@ -621,9 +604,15 @@ mod tests { MockAggregateVerifier::default(), ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, intermediate_roots) = proof_result(TEST_BLOCK_INTERVAL); let result = submitter - .submit(&aggregate_proposal, &proposals, 25, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit( + &aggregate_proposal, + &intermediate_roots, + 25, + TEST_BLOCK_INTERVAL, + Address::ZERO, + ) .await; assert!(matches!( @@ -634,6 +623,27 @@ mod tests { assert_eq!(*output.created.lock().unwrap(), 0); } + #[tokio::test] + async fn submit_discards_proof_with_wrong_intermediate_root_count() { + let output = Arc::new(MockOutputProposer::default()); + let submitter = submitter( + Arc::clone(&output), + MockDisputeGameFactory::default(), + MockAggregateVerifier::default(), + ); + + let (aggregate_proposal, _) = proof_result(TEST_BLOCK_INTERVAL); + let result = + submitter.submit(&aggregate_proposal, &[], 0, TEST_BLOCK_INTERVAL, Address::ZERO).await; + + assert!(matches!( + result, + Err(SubmitAction::Discard(ref error)) + if error.to_string().contains("proof signed 0 intermediate root(s), the game needs 1") + )); + assert_eq!(*output.created.lock().unwrap(), 0); + } + #[tokio::test] async fn submit_discards_invalid_parent_game_create_error() { let output = Arc::new(MockOutputProposer::with_create_error(ProposerError::Submission( @@ -645,9 +655,9 @@ mod tests { MockAggregateVerifier::default(), ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, intermediate_roots) = proof_result(TEST_BLOCK_INTERVAL); let result = submitter - .submit(&aggregate_proposal, &proposals, 0, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit(&aggregate_proposal, &intermediate_roots, 0, TEST_BLOCK_INTERVAL, Address::ZERO) .await; assert!(matches!( @@ -671,9 +681,9 @@ mod tests { verifier, ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, intermediate_roots) = proof_result(TEST_BLOCK_INTERVAL); let result = submitter - .submit(&aggregate_proposal, &proposals, 0, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit(&aggregate_proposal, &intermediate_roots, 0, TEST_BLOCK_INTERVAL, Address::ZERO) .await; assert!(matches!(result, Err(SubmitAction::GameAlreadyExists))); @@ -707,9 +717,15 @@ mod tests { MockAggregateVerifier::default(), ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, intermediate_roots) = proof_result(TEST_BLOCK_INTERVAL); let result = submitter - .submit(&aggregate_proposal, &proposals, 0, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit( + &aggregate_proposal, + &intermediate_roots, + 0, + TEST_BLOCK_INTERVAL, + Address::ZERO, + ) .await; match expected_discard_label { @@ -732,9 +748,9 @@ mod tests { MockAggregateVerifier::default(), ); - let (aggregate_proposal, proposals) = proof_result(TEST_BLOCK_INTERVAL); + let (aggregate_proposal, intermediate_roots) = proof_result(TEST_BLOCK_INTERVAL); let result = submitter - .submit(&aggregate_proposal, &proposals, 0, TEST_BLOCK_INTERVAL, Address::ZERO) + .submit(&aggregate_proposal, &intermediate_roots, 0, TEST_BLOCK_INTERVAL, Address::ZERO) .await; assert!(matches!(result, Err(SubmitAction::GameAlreadyExists))); diff --git a/crates/proof/proposer/src/proposal_intervals.rs b/crates/proof/proposer/src/proposal_intervals.rs index f0f35bdb9f..f1e6c184df 100644 --- a/crates/proof/proposer/src/proposal_intervals.rs +++ b/crates/proof/proposer/src/proposal_intervals.rs @@ -6,7 +6,7 @@ //! Every consumer resolves them from the starting block of the game it is //! about to create, reconstruct, or look up. -use std::sync::Arc; +use std::{num::NonZeroU64, sync::Arc}; use base_proof_contracts::{ AggregateVerifierClient, DisputeGameFactoryClient, game_lookup_blocks, resolve_intervals, @@ -20,7 +20,7 @@ pub struct Intervals { /// Number of L2 blocks covered by one proposal. pub block_interval: u64, /// Number of L2 blocks between intermediate output root checkpoints. - pub intermediate_block_interval: u64, + pub intermediate_block_interval: NonZeroU64, } impl Intervals { @@ -33,7 +33,7 @@ impl Intervals { game_lookup_blocks( starting_block_number, self.block_interval, - self.intermediate_block_interval, + self.intermediate_block_interval.get(), ) .map_err(|e| ProposerError::Config(e.to_string())) } @@ -94,6 +94,12 @@ impl IntervalResolver { "interval lookup for block {starting_block} failed: {e}" )) })?; + let intermediate_block_interval = + NonZeroU64::new(intermediate_block_interval).ok_or_else(|| { + ProposerError::Contract(format!( + "intermediate block interval for block {starting_block} is zero" + )) + })?; Ok(Intervals { block_interval, intermediate_block_interval }) } diff --git a/crates/proof/proposer/src/test_utils.rs b/crates/proof/proposer/src/test_utils.rs index a15707f8a6..d3196ae94e 100644 --- a/crates/proof/proposer/src/test_utils.rs +++ b/crates/proof/proposer/src/test_utils.rs @@ -16,7 +16,7 @@ use base_proof_contracts::{ AnchorStateRegistryClient, ContractError, DisputeGameFactoryClient, GameAtIndex, GameInfo, GameStatus, }; -use base_proof_primitives::Proposal; +use base_proof_primitives::{ProofRequest, Proposal}; use base_proof_rpc::{ BaseBlock, BaseHeader, L1Provider, L2Provider, RollupProvider, RpcError, RpcResult, }; @@ -457,6 +457,12 @@ pub fn test_proposal(block_number: u64) -> Proposal { } } +/// Creates a zeroed TEE [`ProofRequest`] for a game with one intermediate root +/// at the mock verifier's default block interval. +pub fn test_proof_request() -> ProofRequest { + ProofRequest::for_tests(0, MockAggregateVerifier::default().block_interval) +} + /// Mock prover-service requester for pipeline tests. #[derive(Debug, Default)] pub struct MockProofRequester { @@ -531,11 +537,21 @@ impl ProofRequesterProvider for MockProofRequester { )); }; let target = tee_request.proof.claimed_l2_block_number; - // Proof requests no longer carry a stride. This fake covers one game at the - // mock verifier's default block interval, which is what proposer tests use. - let span = MockAggregateVerifier::default().block_interval.max(1); + // Sample like the enclave over one game at the mock verifier's default block interval, + // which is what proposer tests use. + let span = MockAggregateVerifier::default().block_interval; + let interval = tee_request.proof.intermediate_block_interval.get(); let start = target.saturating_sub(span); - let proposals = ((start + 1)..=target).map(test_proposal).collect::>(); + let intermediate_roots = (1..=span / interval) + .map(|i| start + i * interval) + .map(|block| { + if block == target { + tee_request.proof.claimed_l2_output_root + } else { + test_proposal(block).output_root + } + }) + .collect(); let aggregate_proposal = Proposal { output_root: tee_request.proof.claimed_l2_output_root, l1_origin_hash: tee_request.proof.l1_head, @@ -549,7 +565,7 @@ impl ProofRequesterProvider for MockProofRequester { error_message: None, result: Some(ApiProofResult::Tee(TeeProofResult { aggregate_proposal, - proposals, + intermediate_roots, tee_kind: TeeKind::AwsNitro, tee_signer: Address::repeat_byte(0x11), })), diff --git a/crates/proof/prover-service/client/src/requester.rs b/crates/proof/prover-service/client/src/requester.rs index 3f0cdb08c6..6507602488 100644 --- a/crates/proof/prover-service/client/src/requester.rs +++ b/crates/proof/prover-service/client/src/requester.rs @@ -292,6 +292,7 @@ mod tests { use std::{ collections::VecDeque, net::SocketAddr, + num::NonZeroU64, sync::{ Arc, Mutex, atomic::{AtomicU32, Ordering}, @@ -555,6 +556,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), diff --git a/crates/proof/prover-service/client/src/worker.rs b/crates/proof/prover-service/client/src/worker.rs index 9866fa0a24..d4fa488669 100644 --- a/crates/proof/prover-service/client/src/worker.rs +++ b/crates/proof/prover-service/client/src/worker.rs @@ -300,6 +300,7 @@ mod tests { use std::{ collections::VecDeque, net::SocketAddr, + num::NonZeroU64, sync::{ Arc, Mutex, atomic::{AtomicU32, Ordering}, @@ -751,6 +752,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(2).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), diff --git a/crates/proof/prover-service/db/Cargo.toml b/crates/proof/prover-service/db/Cargo.toml index 5e2ed93772..7ec2bd8d59 100644 --- a/crates/proof/prover-service/db/Cargo.toml +++ b/crates/proof/prover-service/db/Cargo.toml @@ -30,8 +30,8 @@ tracing.workspace = true [dev-dependencies] alloy-primitives.workspace = true -base-proof-primitives.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +base-proof-primitives = { workspace = true, features = ["test-utils"] } [lints] workspace = true diff --git a/crates/proof/prover-service/db/src/conversions.rs b/crates/proof/prover-service/db/src/conversions.rs index 0b52e3dec7..ed0d28e7d3 100644 --- a/crates/proof/prover-service/db/src/conversions.rs +++ b/crates/proof/prover-service/db/src/conversions.rs @@ -260,6 +260,8 @@ impl ProofRequest { #[cfg(test)] mod tests { + use std::num::NonZeroU64; + use base_prover_service_protocol::{ ProofRequest as ProtocolProofRequest, ProofRequestKind, ZkBackend as ProtocolZkBackend, }; @@ -277,6 +279,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(2).unwrap(), zk_vm: ProtocolZkVm::Sp1, zk_backend: ProtocolZkBackend::Cluster, }), @@ -483,7 +486,7 @@ mod tests { "prev_output_root": "0x0000000000000000000000000000000000000000000000000000000000000000", "config_hash": "0x0000000000000000000000000000000000000000000000000000000000000000" }, - "proposals": [], + "intermediate_roots": [], "tee_kind": "aws_nitro", "tee_signer": "0x1111111111111111111111111111111111111111" } diff --git a/crates/proof/prover-service/db/src/models.rs b/crates/proof/prover-service/db/src/models.rs index 3b72aaf8c3..1d69353574 100644 --- a/crates/proof/prover-service/db/src/models.rs +++ b/crates/proof/prover-service/db/src/models.rs @@ -1212,6 +1212,8 @@ pub struct FailExpiredProofJobs<'a> { #[cfg(test)] mod tests { + use std::num::NonZeroU64; + use base_prover_service_protocol::{ SnarkPlonkProofResult, ZkBackend, ZkProofRequest, ZkProofResult, ZkVm, }; @@ -1255,6 +1257,7 @@ mod tests { sequence_window: Some(50), l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), diff --git a/crates/proof/prover-service/db/src/repo.rs b/crates/proof/prover-service/db/src/repo.rs index 7efa489c78..929107f041 100644 --- a/crates/proof/prover-service/db/src/repo.rs +++ b/crates/proof/prover-service/db/src/repo.rs @@ -1505,9 +1505,6 @@ fn compatibility_receipts_for_result( } } -const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; -const ZERO_HASH: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; - // Legacy rows predate `api_proof_type`; valid legacy ZK rows always have `proof_type`. // Treat `None` as compressed only to keep reads tolerant of inconsistent pre-migration data. const fn api_proof_type_for_backend(proof_type: Option) -> ApiProofType { @@ -1524,96 +1521,6 @@ const fn fallback_zk_vm_for_request(api_proof_type: ApiProofType) -> Option Option { - match api_proof_type { - ApiProofType::Compressed | ApiProofType::SnarkPlonk => Some(ZkBackend::Cluster), - ApiProofType::Tee => None, - } -} - -/// Fields needed to build the canonical protocol request payload. -#[derive(Debug, Clone, Copy)] -struct ProtocolRequestPayloadParams<'a> { - session_id: &'a str, - start_block_number: i64, - number_of_blocks_to_prove: i64, - sequence_window: Option, - api_proof_type: ApiProofType, - tee_kind: Option, - zk_backend: Option, - prover_address: Option<&'a str>, - l1_head: Option<&'a str>, -} - -impl ProtocolRequestPayloadParams<'_> { - fn build(self) -> serde_json::Value { - let mut zk_payload = serde_json::json!({ - "start_block_number": self.start_block_number, - "number_of_blocks_to_prove": self.number_of_blocks_to_prove, - "sequence_window": self.sequence_window, - "l1_head": self.l1_head, - "zk_vm": ZkVmKind::Sp1.as_str(), - "zk_backend": self.zk_backend.unwrap_or(ZkBackend::Cluster).as_str(), - }); - strip_null_object_fields(&mut zk_payload); - - match self.api_proof_type { - ApiProofType::Compressed => serde_json::json!({ - "session_id": self.session_id, - "request": { - "proof_type": ApiProofType::Compressed.as_str(), - "payload": zk_payload, - }, - }), - ApiProofType::SnarkPlonk => serde_json::json!({ - "session_id": self.session_id, - "request": { - "proof_type": ApiProofType::SnarkPlonk.as_str(), - "payload": { - "proof": zk_payload, - "prover_address": self.prover_address.unwrap_or(ZERO_ADDRESS), - }, - }, - }), - ApiProofType::Tee => serde_json::json!({ - "session_id": self.session_id, - "request": { - "proof_type": ApiProofType::Tee.as_str(), - "payload": { - "proof": { - "l1_head": self.l1_head.unwrap_or(ZERO_HASH), - "agreed_l2_head_hash": ZERO_HASH, - "agreed_l2_output_root": ZERO_HASH, - "claimed_l2_output_root": ZERO_HASH, - "claimed_l2_block_number": self.start_block_number, - "proposer": ZERO_ADDRESS, - "l1_head_number": 0, - }, - "tee_kind": self.tee_kind.unwrap_or(TeeKind::AwsNitro).as_str(), - }, - }, - }), - } - } -} - -fn strip_null_object_fields(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - map.retain(|_, value| { - strip_null_object_fields(value); - !value.is_null() - }); - } - serde_json::Value::Array(values) => { - for value in values { - strip_null_object_fields(value); - } - } - _ => {} - } -} - fn ensure_protocol_session_id(value: &mut serde_json::Value, session_id: &str) { if let serde_json::Value::Object(map) = value && map.get("session_id").is_none_or(|value| value.is_null()) @@ -1656,27 +1563,11 @@ fn row_to_proof_request(row: &sqlx::postgres::PgRow) -> Result { .map(parse_zk_vm_kind) .transpose()? .or_else(|| fallback_zk_vm_for_request(api_proof_type)); - let zk_backend = row - .get::, _>("zk_backend") - .map(parse_zk_backend) - .transpose()? - .or_else(|| fallback_zk_backend_for_request(api_proof_type)); let tee_kind = row.get::, _>("tee_kind").map(parse_tee_kind).transpose()?; let mut request_payload = - row.get::, _>("request_payload").unwrap_or_else(|| { - ProtocolRequestPayloadParams { - session_id: &session_id, - start_block_number, - number_of_blocks_to_prove, - sequence_window, - api_proof_type, - tee_kind, - zk_backend, - prover_address: prover_address.as_deref(), - l1_head: l1_head.as_deref(), - } - .build() - }); + row.get::, _>("request_payload").ok_or_else(|| { + sqlx::Error::Protocol(format!("proof request {session_id} has no request_payload")) + })?; ensure_protocol_session_id(&mut request_payload, &session_id); Ok(ProofRequest { @@ -1711,11 +1602,6 @@ fn parse_zk_vm_kind(value: &str) -> Result { .map_err(|e| sqlx::Error::Protocol(format!("Unknown zk_vm '{value}': {e}"))) } -fn parse_zk_backend(value: &str) -> Result { - ZkBackend::try_from(value) - .map_err(|e| sqlx::Error::Protocol(format!("Unknown zk_backend '{value}': {e}"))) -} - fn parse_tee_kind(value: &str) -> Result { TeeKind::try_from(value) .map_err(|e| sqlx::Error::Protocol(format!("Unknown tee_kind '{value}': {e}"))) @@ -1968,9 +1854,6 @@ fn comparable_request_payload( }; if let Some(map) = zk_backend_path.and_then(|path| value.pointer_mut(path)?.as_object_mut()) { map.entry("zk_backend").or_insert_with(|| serde_json::Value::String("cluster".to_owned())); - // Older ZK payloads recorded a caller-supplied interval. The range program - // ignores it, so it must not split an otherwise identical replay. - map.remove("intermediate_root_interval"); } if mode == RequestMismatchMode::AllowL1HeadReplacement { remove_l1_head_fields(&mut value); @@ -1994,6 +1877,9 @@ fn remove_l1_head_fields(value: &mut serde_json::Value) { #[cfg(test)] mod tests { + use std::num::NonZeroU64; + + use base_proof_primitives::ProofRequest as PrimitiveProofRequest; use base_prover_service_protocol::{ ProofRequest as ProtocolProofRequest, ProofRequestKind, TeeKind as ProtocolTeeKind, TeeProofRequest, ZkBackend, ZkProofRequest, ZkVm, @@ -2012,6 +1898,7 @@ mod tests { sequence_window: Some(50), l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), @@ -2047,6 +1934,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), @@ -2062,7 +1950,6 @@ mod tests { assert!(!payload.contains_key("sequence_window")); assert!(!payload.contains_key("l1_head")); - assert!(!payload.contains_key("intermediate_root_interval")); assert!(!payload.contains_key("schedule_l2_block_number")); } @@ -2073,42 +1960,10 @@ mod tests { assert!(zk_vm.is_none()); } - #[test] - fn fallback_tee_protocol_payload_deserializes() { - let payload = ProtocolRequestPayloadParams { - session_id: "tee-session", - start_block_number: 100, - number_of_blocks_to_prove: 1, - sequence_window: None, - api_proof_type: ApiProofType::Tee, - tee_kind: Some(TeeKind::AwsNitro), - zk_backend: None, - prover_address: None, - l1_head: Some(ZERO_HASH), - } - .build(); - - let protocol_request: ProtocolProofRequest = - serde_json::from_value(payload).expect("fallback TEE payload should deserialize"); - - assert_eq!(protocol_request.session_id, "tee-session"); - let ProofRequestKind::Tee(request) = protocol_request.request else { - panic!("expected TEE protocol request"); - }; - assert_eq!(request.tee_kind, ProtocolTeeKind::AwsNitro); - assert_eq!(request.proof.claimed_l2_block_number, 100); - } - #[test] fn prepared_request_represents_tee_protocol_request() { - let create = CreateProofRequest::new(ProtocolProofRequest { - session_id: "tee-session".to_owned(), - request: ProofRequestKind::Tee(TeeProofRequest { - proof: Default::default(), - tee_kind: ProtocolTeeKind::AwsNitro, - }), - }) - .expect("TEE request should validate"); + let create = CreateProofRequest::new(tee_protocol_request("tee-session")) + .expect("TEE request should validate"); let prepared = PreparedProofRequest::try_from(create).expect("TEE request should prepare"); @@ -2185,7 +2040,7 @@ mod tests { serde_json::json!({ "request": { "proof_type": "compressed", - "payload": {"start_block_number": 1, "intermediate_root_interval": 10} + "payload": {"start_block_number": 1} } }), serde_json::json!({ @@ -2222,7 +2077,7 @@ mod tests { ProtocolProofRequest { session_id: session_id.to_owned(), request: ProofRequestKind::Tee(TeeProofRequest { - proof: Default::default(), + proof: PrimitiveProofRequest::for_tests(600, 30), tee_kind: ProtocolTeeKind::AwsNitro, }), } @@ -2235,14 +2090,8 @@ mod tests { #[test] fn prepared_request_rejects_unsupported_protocol_combination() { - let mut create = CreateProofRequest::new(ProtocolProofRequest { - session_id: "bad-tee-session".to_owned(), - request: ProofRequestKind::Tee(TeeProofRequest { - proof: Default::default(), - tee_kind: ProtocolTeeKind::AwsNitro, - }), - }) - .expect("TEE request should validate"); + let mut create = CreateProofRequest::new(tee_protocol_request("bad-tee-session")) + .expect("TEE request should validate"); create.zk_vm = Some(ZkVmKind::Sp1); let err = @@ -2261,6 +2110,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), diff --git a/crates/proof/prover-service/db/tests/postgres_integration.rs b/crates/proof/prover-service/db/tests/postgres_integration.rs index 13f4787d5c..7430971abc 100644 --- a/crates/proof/prover-service/db/tests/postgres_integration.rs +++ b/crates/proof/prover-service/db/tests/postgres_integration.rs @@ -13,10 +13,10 @@ //! They run sequentially (`--test-threads=1`) because they share the same database; //! each test creates unique UUIDs so they don't collide. -use std::time::Duration; +use std::{num::NonZeroU64, time::Duration}; use alloy_primitives::Address; -use base_proof_primitives::Proposal; +use base_proof_primitives::{ProofRequest as PrimitiveProofRequest, Proposal}; use base_prover_service_db::{ AbandonProofJob, AbandonProofOutcome, ApiProofType, CancelProofRequestOutcome, ClaimProofJob, CompleteClaimedProofJob, CreateProofRequest, CreateProofRequestError, @@ -74,6 +74,7 @@ fn compressed_request_at_with_backend( sequence_window: Some(50), l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), zk_vm: ZkVm::Sp1, zk_backend, }), @@ -90,6 +91,7 @@ fn compressed_request_with_l1_head(l1_head: &str) -> CreateProofRequest { sequence_window: Some(50), l1_head: Some(l1_head.parse().expect("valid hash")), schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), @@ -111,6 +113,7 @@ fn snark_request() -> CreateProofRequest { .expect("valid hash"), ), schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(10).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }, @@ -126,7 +129,7 @@ fn tee_request() -> CreateProofRequest { CreateProofRequest::new(ProtocolProofRequest { session_id: Uuid::new_v4().to_string(), request: ProtocolProofRequestKind::Tee(TeeProofRequest { - proof: Default::default(), + proof: PrimitiveProofRequest::for_tests(600, 30), tee_kind: ProtocolTeeKind::AwsNitro, }), }) @@ -1543,7 +1546,7 @@ async fn test_delete_proof_requests_by_tee_signer() { config_hash: Default::default(), schedule_id: Default::default(), }, - proposals: vec![], + intermediate_roots: vec![], tee_kind: ProtocolTeeKind::AwsNitro, tee_signer: submitted_signer.parse().unwrap(), }), diff --git a/crates/proof/prover-service/protocol/src/types.rs b/crates/proof/prover-service/protocol/src/types.rs index a6a148930c..b6de6bf451 100644 --- a/crates/proof/prover-service/protocol/src/types.rs +++ b/crates/proof/prover-service/protocol/src/types.rs @@ -1,6 +1,6 @@ //! JSON-RPC request and response types for the shared prover service protocol. -use std::{collections::HashMap, fmt}; +use std::{collections::HashMap, fmt, num::NonZeroU64}; use alloy_primitives::{Address, B256, Bytes}; use base_proof_primitives::{ProofRequest as PrimitiveProofRequest, Proposal}; @@ -209,6 +209,10 @@ pub struct ZkProofRequest { /// L2 block used to pin the upgrade schedule; defaults to the claimed block. #[serde(default, skip_serializing_if = "Option::is_none")] pub schedule_l2_block_number: Option, + /// Number of L2 blocks between two intermediate output roots of the game being proven. + /// + /// `number_of_blocks_to_prove` must be a multiple of it. + pub intermediate_block_interval: NonZeroU64, /// ZK virtual machine implementation to use. pub zk_vm: ZkVm, /// Proving backend that should execute this request. @@ -285,10 +289,10 @@ pub struct SnarkPlonkProofResult { /// TEE proof result. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TeeProofResult { - /// Aggregate proposal covering the entire proven block range. + /// Signed proposal covering the entire proven block range. pub aggregate_proposal: Proposal, - /// Individual signed proposals. - pub proposals: Vec, + /// Intermediate output roots committed by the aggregate signature. + pub intermediate_roots: Vec, /// Trusted execution environment implementation that produced the proof. pub tee_kind: TeeKind, /// Signer that produced the proof, recovered host-side from the proof @@ -582,6 +586,7 @@ mod tests { sequence_window: None, l1_head: Some(B256::repeat_byte(0xab)), schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(10).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), @@ -602,6 +607,7 @@ mod tests { "start_block_number": 10, "number_of_blocks_to_prove": 20, "l1_head": format!("{:#x}", B256::repeat_byte(0xab)), + "intermediate_block_interval": 10, "zk_vm": "sp1", "zk_backend": "cluster" } @@ -622,6 +628,7 @@ mod tests { "payload": { "start_block_number": 10, "number_of_blocks_to_prove": 20, + "intermediate_block_interval": 10, "zk_vm": "sp1" } } @@ -640,6 +647,7 @@ mod tests { "payload": { "start_block_number": 10, "number_of_blocks_to_prove": 20, + "intermediate_block_interval": 10, "zk_vm": "sp1" } } @@ -703,6 +711,7 @@ mod tests { proposer: address!("0000000000000000000000000000000000000006"), l1_head_number: 8, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), }, tee_kind: TeeKind::AwsNitro, }; @@ -720,6 +729,7 @@ mod tests { "claimed_l2_block_number": 5, "proposer": "0x0000000000000000000000000000000000000006", "l1_head_number": 8, + "intermediate_block_interval": 5, }, "tee_kind": "aws_nitro", }) @@ -764,10 +774,9 @@ mod tests { #[test] fn tee_result_uses_flat_proposal_shape() { let aggregate_proposal = tee_proposal(1, 20); - let proposal = tee_proposal(2, 19); let result = ProofResult::Tee(TeeProofResult { aggregate_proposal: aggregate_proposal.clone(), - proposals: vec![proposal.clone()], + intermediate_roots: vec![B256::repeat_byte(9), aggregate_proposal.output_root], tee_kind: TeeKind::AwsNitro, tee_signer: Address::repeat_byte(0x11), }); @@ -790,17 +799,11 @@ mod tests { }) ); assert_eq!( - value["payload"]["proposals"][0], - json!({ - "output_root": format!("{:#x}", proposal.output_root), - "signature": "0x020202", - "l1_origin_hash": format!("{:#x}", proposal.l1_origin_hash), - "l1_origin_number": proposal.l1_origin_number, - "l2_block_number": proposal.l2_block_number, - "prev_output_root": format!("{:#x}", proposal.prev_output_root), - "config_hash": format!("{:#x}", proposal.config_hash), - "schedule_id": format!("{:#x}", proposal.schedule_id), - }) + value["payload"]["intermediate_roots"], + json!([ + format!("{:#x}", B256::repeat_byte(9)), + format!("{:#x}", aggregate_proposal.output_root), + ]) ); } @@ -820,7 +823,7 @@ mod tests { "prev_output_root": format!("{:#x}", aggregate_proposal.prev_output_root), "config_hash": format!("{:#x}", aggregate_proposal.config_hash), }, - "proposals": [] + "intermediate_roots": [] }, "tee_kind": "aws_nitro" } @@ -831,20 +834,12 @@ mod tests { #[test] fn tee_result_requires_aggregate_proposal() { - let proposal = tee_proposal(2, 19); let result = serde_json::from_value::(json!({ "proof_type": "tee", "payload": { - "proposals": [{ - "output_root": format!("{:#x}", proposal.output_root), - "signature": "0x020202", - "l1_origin_hash": format!("{:#x}", proposal.l1_origin_hash), - "l1_origin_number": proposal.l1_origin_number, - "l2_block_number": proposal.l2_block_number, - "prev_output_root": format!("{:#x}", proposal.prev_output_root), - "config_hash": format!("{:#x}", proposal.config_hash), - }], - "tee_kind": "aws_nitro" + "intermediate_roots": [format!("{:#x}", B256::repeat_byte(2))], + "tee_kind": "aws_nitro", + "tee_signer": "0x1111111111111111111111111111111111111111" } })); @@ -862,6 +857,7 @@ mod tests { proposer: address!("0000000000000000000000000000000000000006"), l1_head_number: 8, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(5).unwrap(), }; let value = serde_json::to_value(request).expect("tee request payload should serialize"); @@ -876,6 +872,7 @@ mod tests { "claimed_l2_block_number": 5, "proposer": "0x0000000000000000000000000000000000000006", "l1_head_number": 8, + "intermediate_block_interval": 5, }) ); } @@ -883,10 +880,9 @@ mod tests { #[test] fn tee_proposal_uses_hex_encoded_fixed_values() { let aggregate_proposal = tee_proposal(1, 20); - let proposal = tee_proposal(2, 19); let result = TeeProofResult { aggregate_proposal: aggregate_proposal.clone(), - proposals: vec![proposal.clone()], + intermediate_roots: vec![aggregate_proposal.output_root], tee_kind: TeeKind::AwsNitro, tee_signer: Address::repeat_byte(0x11), }; @@ -906,19 +902,6 @@ mod tests { "schedule_id": format!("{:#x}", aggregate_proposal.schedule_id), }) ); - assert_eq!( - value["proposals"][0], - json!({ - "output_root": format!("{:#x}", proposal.output_root), - "signature": "0x020202", - "l1_origin_hash": format!("{:#x}", proposal.l1_origin_hash), - "l1_origin_number": proposal.l1_origin_number, - "l2_block_number": proposal.l2_block_number, - "prev_output_root": format!("{:#x}", proposal.prev_output_root), - "config_hash": format!("{:#x}", proposal.config_hash), - "schedule_id": format!("{:#x}", proposal.schedule_id), - }) - ); } #[test] @@ -926,19 +909,11 @@ mod tests { let request: ZkProofRequest = serde_json::from_value(json!({ "start_block_number": 10, "number_of_blocks_to_prove": 20, + "intermediate_block_interval": 10, "zk_vm": "sp1" })) .expect("zk request should accept omitted optional fields"); - let with_legacy_interval: ZkProofRequest = serde_json::from_value(json!({ - "start_block_number": 10, - "number_of_blocks_to_prove": 20, - "intermediate_root_interval": 30, - "zk_vm": "sp1" - })) - .expect("zk request should ignore a legacy intermediate root interval"); - assert_eq!(with_legacy_interval, request); - assert_eq!( request, ZkProofRequest { @@ -947,6 +922,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(10).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, } @@ -961,6 +937,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: Some(42), + intermediate_block_interval: NonZeroU64::new(10).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }; @@ -979,6 +956,7 @@ mod tests { "start_block_number": 10, "number_of_blocks_to_prove": 20, "l1_head": "0xabc", + "intermediate_block_interval": 10, "zk_vm": "sp1" })); diff --git a/crates/proof/prover-service/tests/idempotency.rs b/crates/proof/prover-service/tests/idempotency.rs index 2c7957d694..dbfbc0e9fa 100644 --- a/crates/proof/prover-service/tests/idempotency.rs +++ b/crates/proof/prover-service/tests/idempotency.rs @@ -5,6 +5,8 @@ mod common; +use std::num::NonZeroU64; + use base_prover_service_protocol::{ ProofRequest, ProofRequestKind, ProveBlockRangeRequest, ProverRequesterApiClient, ZkBackend, ZkProofRequest, ZkVm, @@ -22,6 +24,7 @@ fn compressed_request(session_id: &str, start_block_number: u64) -> ProveBlockRa sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(1).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), diff --git a/crates/proof/prover-service/tests/worker_api_e2e.rs b/crates/proof/prover-service/tests/worker_api_e2e.rs index 8e4a2ef68c..014f81050e 100644 --- a/crates/proof/prover-service/tests/worker_api_e2e.rs +++ b/crates/proof/prover-service/tests/worker_api_e2e.rs @@ -11,7 +11,7 @@ //! //! Tests are marked `#[ignore]` so they're skipped by default. -use std::{net::SocketAddr, time::Duration}; +use std::{net::SocketAddr, num::NonZeroU64, time::Duration}; use base_prover_service::{ProverServiceServer, ServerConfig, WorkerApiConfig, WorkerQueueConfig}; use base_prover_service_db::{ @@ -129,6 +129,7 @@ fn compressed_request(session_id: &str, start_block_number: u64) -> CreateProofR sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(1).unwrap(), zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), diff --git a/crates/proof/tee/nitro-enclave/src/error.rs b/crates/proof/tee/nitro-enclave/src/error.rs index 4291fcbd73..8b7c72fb45 100644 --- a/crates/proof/tee/nitro-enclave/src/error.rs +++ b/crates/proof/tee/nitro-enclave/src/error.rs @@ -43,12 +43,6 @@ pub enum CryptoError { /// Errors that can occur during proposal operations. #[derive(Debug, Clone, Error)] pub enum ProposalError { - /// No proposals provided for aggregation. - #[error("no proposals provided for aggregation")] - EmptyProposals, - /// Intermediate block interval must not be zero. - #[error("intermediate_block_interval must not be zero")] - InvalidInterval, /// Signature verification failed at the given index. #[error("invalid signature at proposal index {index}: {reason}")] InvalidSignature { diff --git a/crates/proof/tee/nitro-enclave/src/server.rs b/crates/proof/tee/nitro-enclave/src/server.rs index b98c5f8597..0693b4cf1f 100644 --- a/crates/proof/tee/nitro-enclave/src/server.rs +++ b/crates/proof/tee/nitro-enclave/src/server.rs @@ -6,7 +6,7 @@ use alloy_signer_local::PrivateKeySigner; use base_common_chains::ChainConfig; use base_common_evm::BaseEvmFactory; use base_common_genesis::RollupConfig; -use base_proof::BootInfo; +use base_proof::{BootInfo, IntermediateRootSampler}; use base_proof_client::Prologue; use base_proof_preimage::PreimageKey; use base_proof_primitives::{PerChainConfig, ProofJournal, ProofResult, Proposal}; @@ -14,7 +14,7 @@ use tracing::info; use crate::{ Ecdsa, NsmRng, NsmSession, Oracle, Signing, - error::{NitroError, NsmError, ProposalError, Result}, + error::{NitroError, NsmError, Result}, }; /// Environment variable for setting the signer key in local mode. @@ -138,8 +138,9 @@ impl Server { session.get_attestation(public_key, user_data, nonce) } - /// Run the proof-client pipeline for the given preimages and return per-block proposals - /// with an aggregate. + /// Run the proof-client pipeline for the given preimages and sign one aggregate proposal + /// covering the whole range, committing to the roots sampled at the game's intermediate + /// block interval. pub async fn prove( &self, preimages: impl IntoIterator)>, @@ -149,7 +150,6 @@ impl Server { let boot_info = BootInfo::load(&oracle).await.map_err(|e| NitroError::ProofPipeline(e.to_string()))?; let config_hash = config_hash_for_chain(boot_info.chain_id)?; - let agreed_l2_output_root = boot_info.agreed_l2_output_root; let prologue = Prologue::new(oracle.clone(), oracle, BaseEvmFactory::default()); let driver = prologue.load().await.map_err(|e| NitroError::ProofPipeline(e.to_string()))?; @@ -158,100 +158,52 @@ impl Server { .await .map_err(|e| NitroError::ProofPipeline(e.to_string()))?; - if block_results.is_empty() { - return Err(ProposalError::EmptyProposals.into()); + let mut sampler = IntermediateRootSampler::new(boot_info.intermediate_block_interval); + for (_, output_root) in &block_results { + sampler.push(*output_root); } + let intermediate_roots = + sampler.finish().map_err(|e| NitroError::ProofPipeline(e.to_string()))?; // Trust-critical: validate final output root against claim + let output_root = epilogue.output_root; + let ending_l2_block = epilogue.safe_head.block_info.number; epilogue.validate().map_err(|e| NitroError::ProofPipeline(e.to_string()))?; - - let mut proposals = Vec::with_capacity(block_results.len()); - let mut prev_output_root = agreed_l2_output_root; - - let l1_origin_hash = boot_info.l1_head; - let l1_origin_number = boot_info.l1_head_number; - for (l2_info, output_root) in &block_results { - let l2_block_number = l2_info.block_info.number; - - let journal = ProofJournal { - proposer: boot_info.proposer, - l1_origin_hash, - prev_output_root, - starting_l2_block: l2_block_number - .checked_sub(1) - .ok_or_else(|| NitroError::ProofPipeline("l2_block_number is 0".into()))?, - output_root: *output_root, - ending_l2_block: l2_block_number, - intermediate_roots: vec![], - config_hash, - tee_image_hash: self.tee_image_hash, - schedule_id: boot_info.schedule_id, - }; - let signing_data = journal.encode(); - - let signature = Signing::sign(&self.signer_key, &signing_data)?; - - proposals.push(Proposal { - output_root: *output_root, - signature: Bytes::from(signature.to_vec()), - l1_origin_hash, - l1_origin_number, - l2_block_number, - prev_output_root, - config_hash, - schedule_id: boot_info.schedule_id, - }); - - prev_output_root = *output_root; - } - - let aggregate_proposal = if proposals.len() == 1 { - proposals[0].clone() - } else { - let first = &proposals[0]; - let last = proposals.last().unwrap(); - - let interval = boot_info.intermediate_block_interval; - if interval == 0 { - return Err(ProposalError::InvalidInterval.into()); - } - let interval = interval as usize; - let count = proposals.len() / interval; - let intermediate_roots: Vec = - (1..=count).map(|i| proposals[i * interval - 1].output_root).collect(); - - let journal = ProofJournal { - proposer: boot_info.proposer, - l1_origin_hash, - prev_output_root: agreed_l2_output_root, - starting_l2_block: first - .l2_block_number - .checked_sub(1) - .ok_or_else(|| NitroError::ProofPipeline("l2_block_number is 0".into()))?, - output_root: last.output_root, - ending_l2_block: last.l2_block_number, - intermediate_roots, - config_hash, - tee_image_hash: self.tee_image_hash, - schedule_id: boot_info.schedule_id, - }; - let signing_data = journal.encode(); - - let signature = Signing::sign(&self.signer_key, &signing_data)?; - - Proposal { - output_root: last.output_root, - signature: Bytes::from(signature.to_vec()), - l1_origin_hash, - l1_origin_number, - l2_block_number: last.l2_block_number, - prev_output_root: agreed_l2_output_root, - config_hash, - schedule_id: boot_info.schedule_id, - } + let starting_l2_block = + ending_l2_block.checked_sub(block_results.len() as u64).ok_or_else(|| { + NitroError::ProofPipeline("derived range starts before block 0".into()) + })?; + + let journal = ProofJournal { + proposer: boot_info.proposer, + l1_origin_hash: boot_info.l1_head, + prev_output_root: boot_info.agreed_l2_output_root, + starting_l2_block, + output_root, + ending_l2_block, + intermediate_roots: intermediate_roots.clone(), + config_hash, + tee_image_hash: self.tee_image_hash, + schedule_id: boot_info.schedule_id, + }; + let signature = Signing::sign(&self.signer_key, &journal.encode())?; + + let aggregate_proposal = Proposal { + output_root, + signature: Bytes::from(signature.to_vec()), + l1_origin_hash: boot_info.l1_head, + l1_origin_number: boot_info.l1_head_number, + l2_block_number: ending_l2_block, + prev_output_root: boot_info.agreed_l2_output_root, + config_hash, + schedule_id: boot_info.schedule_id, }; - Ok(ProofResult::Tee { aggregate_proposal, proposals, tee_signer: self.signer_address() }) + Ok(ProofResult::Tee { + aggregate_proposal, + intermediate_roots, + tee_signer: self.signer_address(), + }) } } diff --git a/crates/proof/tee/nitro-host/Cargo.toml b/crates/proof/tee/nitro-host/Cargo.toml index cd6c73b0dd..84679ef177 100644 --- a/crates/proof/tee/nitro-host/Cargo.toml +++ b/crates/proof/tee/nitro-host/Cargo.toml @@ -50,6 +50,7 @@ reqwest.workspace = true alloy-genesis.workspace = true tokio = { workspace = true, features = ["macros", "rt"] } base-common-genesis = { workspace = true, features = ["serde", "std"] } +base-proof-primitives = { workspace = true, features = ["test-utils"] } [features] metrics = [ "base-proof-host/metrics" ] diff --git a/crates/proof/tee/nitro-host/src/pool.rs b/crates/proof/tee/nitro-host/src/pool.rs index c0915eaab1..9b4bbaf3d3 100644 --- a/crates/proof/tee/nitro-host/src/pool.rs +++ b/crates/proof/tee/nitro-host/src/pool.rs @@ -244,7 +244,7 @@ mod tests { .try_acquire_owned() .expect("permit should be available before first acquire"); - let err = pool.prove(ProofRequest::default()).await.unwrap_err(); + let err = pool.prove(ProofRequest::for_tests(600, 30)).await.unwrap_err(); assert!(matches!(err, NitroEnclavePoolError::Busy)); assert!(err.is_busy()); diff --git a/crates/proof/tee/nitro-host/src/proof_generator.rs b/crates/proof/tee/nitro-host/src/proof_generator.rs index 286bb224ac..8f56ccf90c 100644 --- a/crates/proof/tee/nitro-host/src/proof_generator.rs +++ b/crates/proof/tee/nitro-host/src/proof_generator.rs @@ -407,9 +407,10 @@ pub enum ProofGeneratorError { #[cfg(test)] mod tests { - use std::{sync::Mutex, time::Duration}; + use std::{num::NonZeroU64, sync::Mutex, time::Duration}; use alloy_genesis::ChainConfig; + use alloy_primitives::{Address, B256}; use async_trait::async_trait; use base_common_genesis::RollupConfig; use base_proof_host::ProverConfig; @@ -586,7 +587,17 @@ mod tests { } fn primitive_request(block: u64) -> NitroProofRequest { - NitroProofRequest { claimed_l2_block_number: block, ..Default::default() } + NitroProofRequest { + l1_head: B256::ZERO, + agreed_l2_head_hash: B256::ZERO, + agreed_l2_output_root: B256::ZERO, + claimed_l2_output_root: B256::ZERO, + claimed_l2_block_number: block, + proposer: Address::ZERO, + l1_head_number: 0, + schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(1).unwrap(), + } } fn test_prover_config() -> ProverConfig { @@ -640,6 +651,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::new(1).unwrap(), zk_vm: base_prover_service_protocol::ZkVm::Sp1, zk_backend: base_prover_service_protocol::ZkBackend::Cluster, }) diff --git a/crates/proof/tee/nitro-host/src/proof_submitter.rs b/crates/proof/tee/nitro-host/src/proof_submitter.rs index 3d6e4971f5..2a25971059 100644 --- a/crates/proof/tee/nitro-host/src/proof_submitter.rs +++ b/crates/proof/tee/nitro-host/src/proof_submitter.rs @@ -26,7 +26,8 @@ impl ProofSubmitterRequest { worker_id: String, proof: NitroProofResult, ) -> Result { - let NitroProofResult::Tee { aggregate_proposal, proposals, tee_signer } = proof else { + let NitroProofResult::Tee { aggregate_proposal, intermediate_roots, tee_signer } = proof + else { return Err(ProofSubmitterRequestError::UnsupportedProofResult); }; @@ -36,7 +37,7 @@ impl ProofSubmitterRequest { worker_id, result: ServiceProofResult::Tee(TeeProofResult { aggregate_proposal, - proposals, + intermediate_roots, tee_kind: TeeKind::AwsNitro, tee_signer, }), @@ -74,7 +75,7 @@ mod tests { "worker-1".to_string(), NitroProofResult::Tee { aggregate_proposal: proposal(10), - proposals: vec![proposal(8), proposal(9), proposal(10)], + intermediate_roots: vec![B256::repeat_byte(6), B256::repeat_byte(1)], tee_signer, }, ) @@ -89,7 +90,7 @@ mod tests { assert_eq!(result.tee_signer, tee_signer); assert_eq!(result.tee_kind, TeeKind::AwsNitro); assert_eq!(result.aggregate_proposal.l2_block_number, 10); - assert_eq!(result.proposals.len(), 3); + assert_eq!(result.intermediate_roots, vec![B256::repeat_byte(6), B256::repeat_byte(1)]); } #[test] diff --git a/crates/proof/worker/src/job_discovery.rs b/crates/proof/worker/src/job_discovery.rs index e6b0a0dff8..06e1e032ad 100644 --- a/crates/proof/worker/src/job_discovery.rs +++ b/crates/proof/worker/src/job_discovery.rs @@ -484,7 +484,10 @@ where #[cfg(test)] mod tests { - use std::sync::{Arc, Mutex}; + use std::{ + num::NonZeroU64, + sync::{Arc, Mutex}, + }; use async_trait::async_trait; use base_prover_service_protocol::{ @@ -635,6 +638,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::MIN, zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, } diff --git a/crates/proof/worker/src/proof_submitter.rs b/crates/proof/worker/src/proof_submitter.rs index ed08009804..2513e60ce1 100644 --- a/crates/proof/worker/src/proof_submitter.rs +++ b/crates/proof/worker/src/proof_submitter.rs @@ -314,6 +314,7 @@ where #[cfg(test)] mod tests { use std::{ + num::NonZeroU64, sync::{Arc, Mutex}, time::Duration, }; @@ -454,6 +455,7 @@ mod tests { sequence_window: None, l1_head: None, schedule_l2_block_number: None, + intermediate_block_interval: NonZeroU64::MIN, zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::Cluster, }), diff --git a/crates/proof/zk/backend/src/succinct/cluster.rs b/crates/proof/zk/backend/src/succinct/cluster.rs index 1d07424fe1..864ab4a83c 100644 --- a/crates/proof/zk/backend/src/succinct/cluster.rs +++ b/crates/proof/zk/backend/src/succinct/cluster.rs @@ -11,7 +11,6 @@ use std::{ use async_trait::async_trait; use base_proof_zk_host::{ZkProver, ZkProverError, ZkSessionState}; -use base_proof_zk_utils::INTERMEDIATE_ROOT_INTERVAL; use base_prover_service_protocol::{ ProofResult, SessionType, SnarkPlonkProofRequest, SnarkPlonkProofResult, ZkProofRequest, ZkProofResult, ZkVm, @@ -624,7 +623,7 @@ impl ClusterZkProver { end_block = end_block, number_of_blocks = request.number_of_blocks_to_prove, sequence_window = sequence_window, - intermediate_root_interval = INTERMEDIATE_ROOT_INTERVAL, + intermediate_block_interval = request.intermediate_block_interval, l1_head = ?request.l1_head, "starting SP1 cluster range proof generation" ); @@ -644,6 +643,7 @@ impl ClusterZkProver { L1HeadSource::Pinned, ), schedule_l2_block_number: request.schedule_l2_block_number, + intermediate_block_interval: request.intermediate_block_interval, }) .await { diff --git a/crates/proof/zk/backend/src/succinct/dry_run.rs b/crates/proof/zk/backend/src/succinct/dry_run.rs index 514b01440a..a0abdf5f40 100644 --- a/crates/proof/zk/backend/src/succinct/dry_run.rs +++ b/crates/proof/zk/backend/src/succinct/dry_run.rs @@ -7,7 +7,6 @@ use std::{ use async_trait::async_trait; use base_proof_zk_host::{ZkProver, ZkProverError, ZkSessionState}; -use base_proof_zk_utils::INTERMEDIATE_ROOT_INTERVAL; use base_prover_service_protocol::{ ExecutionStats, ProofResult, SessionType, SnarkPlonkProofRequest, SnarkPlonkProofResult, ZkProofRequest, ZkProofResult, ZkVm, @@ -163,7 +162,7 @@ impl DryRunZkProver { end_block = end_block, number_of_blocks = request.number_of_blocks_to_prove, sequence_window = sequence_window, - intermediate_root_interval = INTERMEDIATE_ROOT_INTERVAL, + intermediate_block_interval = request.intermediate_block_interval, range_cycle_limit = self.range_cycle_limit, l1_head = ?request.l1_head, "starting dry-run SP1 execution" @@ -184,6 +183,7 @@ impl DryRunZkProver { L1HeadSource::Pinned, ), schedule_l2_block_number: request.schedule_l2_block_number, + intermediate_block_interval: request.intermediate_block_interval, }) .await { diff --git a/crates/proof/zk/backend/src/succinct/network.rs b/crates/proof/zk/backend/src/succinct/network.rs index 36c14ec6ca..dd0fbfe580 100644 --- a/crates/proof/zk/backend/src/succinct/network.rs +++ b/crates/proof/zk/backend/src/succinct/network.rs @@ -10,7 +10,6 @@ use std::{fmt, sync::Arc, time::Duration}; use alloy_primitives::B256; use async_trait::async_trait; use base_proof_zk_host::{ZkProver, ZkProverError, ZkSessionState}; -use base_proof_zk_utils::INTERMEDIATE_ROOT_INTERVAL; use base_prover_service_protocol::{ ProofResult, SessionType, SnarkPlonkProofRequest, SnarkPlonkProofResult, ZkProofRequest, ZkProofResult, ZkVm, @@ -331,7 +330,7 @@ impl NetworkZkProver { end_block = end_block, number_of_blocks = request.number_of_blocks_to_prove, sequence_window = sequence_window, - intermediate_root_interval = INTERMEDIATE_ROOT_INTERVAL, + intermediate_block_interval = request.intermediate_block_interval, l1_head = ?request.l1_head, "starting SP1 Network range proof generation" ); @@ -351,6 +350,7 @@ impl NetworkZkProver { L1HeadSource::Pinned, ), schedule_l2_block_number: request.schedule_l2_block_number, + intermediate_block_interval: request.intermediate_block_interval, }) .await { diff --git a/crates/proof/zk/backend/src/succinct/provider.rs b/crates/proof/zk/backend/src/succinct/provider.rs index 895ad66528..e5aa1048dc 100644 --- a/crates/proof/zk/backend/src/succinct/provider.rs +++ b/crates/proof/zk/backend/src/succinct/provider.rs @@ -1,6 +1,6 @@ //! Witness generation for Succinct ZK proving backends. -use std::{error::Error as StdError, fmt, sync::Arc}; +use std::{error::Error as StdError, fmt, num::NonZeroU64, sync::Arc}; use alloy_primitives::{Address, B256}; use base_l1_head::{L1HeadCalculator, L1HeadError}; @@ -24,6 +24,8 @@ pub struct WitnessParams<'a> { pub l1_head: L1HeadSource<'a>, /// L2 block number whose timestamp determines the activated upgrade schedule. pub schedule_l2_block_number: Option, + /// Number of L2 blocks between two intermediate output roots of the proven game. + pub intermediate_block_interval: NonZeroU64, } /// Source used to select the L1 head hash for witness generation. @@ -210,20 +212,29 @@ impl OpSuccinctWitnessProvider { &self, params: WitnessParams<'_>, ) -> Result { - let WitnessParams { start_block, end_block, l1_head, schedule_l2_block_number } = params; + let start_block = params.start_block; + let end_block = params.end_block; info!( start_block = start_block, end_block = end_block, - l1_head_source = l1_head.variant_name(), + l1_head_source = params.l1_head.variant_name(), + intermediate_block_interval = params.intermediate_block_interval, "starting witness generation" ); - let host_args = match l1_head { + let host_args = match params.l1_head { L1HeadSource::Pinned(hash) => { info!(hash = %hash, "using caller-provided l1_head"); self.host - .fetch(start_block, end_block, Some(hash), false, schedule_l2_block_number) + .fetch( + start_block, + end_block, + Some(hash), + false, + params.schedule_l2_block_number, + params.intermediate_block_interval, + ) .await .map_err(|source| WitnessError::PinnedHostFetch { source: source.into_boxed_dyn_error(), @@ -250,7 +261,8 @@ impl OpSuccinctWitnessProvider { end_block, Some(l1_head_hash), false, - schedule_l2_block_number, + params.schedule_l2_block_number, + params.intermediate_block_interval, ) .await .map_err(|source| WitnessError::SequenceWindowHostFetch { diff --git a/crates/proof/zk/backend/src/succinct/utils/stdin.rs b/crates/proof/zk/backend/src/succinct/utils/stdin.rs index 4784c9f2f8..1efe4b931d 100644 --- a/crates/proof/zk/backend/src/succinct/utils/stdin.rs +++ b/crates/proof/zk/backend/src/succinct/utils/stdin.rs @@ -11,9 +11,8 @@ use sp1_sdk::{HashableKey, SP1Proof, SP1Stdin}; /// Build SP1 stdin from collected witness data. /// -/// The intermediate root sampling interval is sourced from `BootInfo` inside the zkVM -/// (preimage key 9) — the same channel the TEE enclave reads — so it is intentionally not -/// passed through stdin. +/// The game's intermediate block interval reaches the range program as a boot preimage, the same +/// channel the TEE enclave reads, so it is intentionally not passed through stdin. pub fn get_sp1_stdin(witness: DefaultWitnessData) -> Result { let mut stdin = SP1Stdin::default(); let buffer = to_bytes::(&witness)?; diff --git a/crates/proof/zk/host/src/proof_submitter.rs b/crates/proof/zk/host/src/proof_submitter.rs index 2e96cd453c..98d2e0d69e 100644 --- a/crates/proof/zk/host/src/proof_submitter.rs +++ b/crates/proof/zk/host/src/proof_submitter.rs @@ -55,7 +55,7 @@ mod tests { fn tee_result_is_rejected() { let tee_result = ProofResult::Tee(TeeProofResult { aggregate_proposal: proposal(), - proposals: vec![proposal()], + intermediate_roots: vec![proposal().output_root], tee_kind: TeeKind::AwsNitro, tee_signer: alloy_primitives::Address::repeat_byte(0x11), }); diff --git a/crates/proof/zk/programs/succinct/aggregation/src/main.rs b/crates/proof/zk/programs/succinct/aggregation/src/main.rs index 20433836ca..5ec419bdaf 100644 --- a/crates/proof/zk/programs/succinct/aggregation/src/main.rs +++ b/crates/proof/zk/programs/succinct/aggregation/src/main.rs @@ -40,6 +40,9 @@ pub fn main() { // from the same chain and span batch range. assert_eq!(prev_boot_info.rollupConfigHash, boot_info.rollupConfigHash); assert_eq!(prev_boot_info.scheduleId, boot_info.scheduleId); + + // The concatenated intermediate roots must be sampled at one interval. + assert_eq!(prev_boot_info.intermediateBlockInterval, boot_info.intermediateBlockInterval); }); // Verify each range program proof. @@ -95,6 +98,7 @@ pub fn main() { l1Head: agg_inputs.latest_l1_checkpoint_head, rollupConfigHash: last_boot_info.rollupConfigHash, scheduleId: last_boot_info.scheduleId, + intermediateBlockInterval: last_boot_info.intermediateBlockInterval, intermediateRoots: intermediate_roots, }; diff --git a/crates/proof/zk/utils/src/boot.rs b/crates/proof/zk/utils/src/boot.rs index b48ed5510c..d7cf267c88 100644 --- a/crates/proof/zk/utils/src/boot.rs +++ b/crates/proof/zk/utils/src/boot.rs @@ -20,6 +20,10 @@ pub fn hash_rollup_config(config: &RollupConfig) -> B256 { } sol! { + /// Public values committed by the range program. + /// + /// `intermediateBlockInterval` is not part of the onchain journal; the aggregation program + /// uses it to check that all aggregated ranges share one interval. #[derive(Debug, Serialize, Deserialize)] struct BootInfoStruct { bytes32 l1Head; @@ -29,6 +33,7 @@ sol! { uint64 l2BlockNumber; bytes32 rollupConfigHash; bytes32 scheduleId; + uint64 intermediateBlockInterval; bytes intermediateRoots; } } @@ -56,6 +61,7 @@ impl BootInfoStruct { l2BlockNumber: l2_block_number, rollupConfigHash: hash_rollup_config(&boot_info.rollup_config), scheduleId: boot_info.schedule_id, + intermediateBlockInterval: boot_info.intermediate_block_interval.get(), intermediateRoots: Bytes::from( intermediate_roots .iter() @@ -69,6 +75,8 @@ impl BootInfoStruct { #[cfg(test)] mod tests { + use std::num::NonZeroU64; + use alloy_primitives::{Address, b256}; use base_common_chains::ChainConfig; @@ -93,7 +101,7 @@ mod tests { rollup_config, l1_config, proposer: Address::ZERO, - intermediate_block_interval: 0, + intermediate_block_interval: NonZeroU64::new(10).unwrap(), l1_head_number: 0, schedule_id: B256::repeat_byte(0x44), } diff --git a/crates/proof/zk/utils/src/client.rs b/crates/proof/zk/utils/src/client.rs index 9c815c402a..895e725eb2 100644 --- a/crates/proof/zk/utils/src/client.rs +++ b/crates/proof/zk/utils/src/client.rs @@ -9,9 +9,7 @@ use anyhow::Result; use base_common_consensus::{BaseBlock, BaseTxEnvelope, OpTxType}; use base_common_genesis::RollupConfig; use base_consensus_derive::{Pipeline, PipelineError, PipelineErrorKind, Signal, SignalReceiver}; -/// Fixed interval (in blocks) at which the ZK range program records intermediate output roots. -pub use base_proof::INTERMEDIATE_BLOCK_INTERVAL as INTERMEDIATE_ROOT_INTERVAL; -use base_proof::{HintType, OracleProviderError}; +use base_proof::{HintType, IntermediateRootSampler, OracleProviderError}; use base_proof_driver::{Driver, DriverError, DriverPipeline, DriverResult, Executor, TipCursor}; use base_proof_preimage::{CommsClient, PreimageKey}; use base_protocol::L2BlockInfo; @@ -48,27 +46,24 @@ where /// ## Takes /// - `cfg`: The rollup configuration. /// - `target`: The target block number. -/// -/// Intermediate output roots are recorded every [`INTERMEDIATE_ROOT_INTERVAL`] blocks. +/// - `intermediate_roots`: Sampler fed with the output root of every derived block. /// /// ## Returns /// - `Ok((l2_safe_head, output_root, intermediate_roots))` - A tuple containing the [`L2BlockInfo`] -/// of the produced block, the output root, and the intermediate output roots at -/// [`INTERMEDIATE_ROOT_INTERVAL`]. +/// of the produced block, the output root, and the sampler for the caller to finish. /// - `Err(e)` - An error if the block could not be produced. #[allow(clippy::result_large_err)] pub async fn advance_to_target( driver: &mut Driver, cfg: &RollupConfig, mut target: Option, -) -> DriverResult<(L2BlockInfo, B256, Vec), E::Error> + mut intermediate_roots: IntermediateRootSampler, +) -> DriverResult<(L2BlockInfo, B256, IntermediateRootSampler), E::Error> where E: Executor + Send + Sync + Debug, DP: DriverPipeline

+ Send + Sync + Debug, P: Pipeline + SignalReceiver + Send + Sync + Debug, { - let mut blocks_processed: u64 = 0; - let mut intermediate_roots: Vec = Vec::new(); loop { // Check if we have reached the target block number. let pipeline_cursor = driver.cursor.read(); @@ -187,10 +182,7 @@ where let l2_info = L2BlockInfo::from_block_and_genesis(&block, &driver.pipeline.rollup_config().genesis)?; let output_root = driver.executor.compute_output_root().map_err(DriverError::Executor)?; - blocks_processed += 1; - if blocks_processed.is_multiple_of(INTERMEDIATE_ROOT_INTERVAL) { - intermediate_roots.push(output_root); - } + intermediate_roots.push(output_root); let tip_cursor = TipCursor::new(l2_info, outcome.header, output_root); // Advance the derivation pipeline cursor diff --git a/crates/proof/zk/utils/src/lib.rs b/crates/proof/zk/utils/src/lib.rs index da03746eff..a8fa97c9ea 100644 --- a/crates/proof/zk/utils/src/lib.rs +++ b/crates/proof/zk/utils/src/lib.rs @@ -15,7 +15,6 @@ extern crate alloc; /// High-level client helpers for derivation and execution. pub mod client; -pub use client::INTERMEDIATE_ROOT_INTERVAL; /// Witness data, preimage storage, and block execution. pub mod witness; diff --git a/crates/proof/zk/utils/src/witness/executor.rs b/crates/proof/zk/utils/src/witness/executor.rs index ba2eb7f0f6..151cc8a3c3 100644 --- a/crates/proof/zk/utils/src/witness/executor.rs +++ b/crates/proof/zk/utils/src/witness/executor.rs @@ -8,8 +8,8 @@ use anyhow::{Result, anyhow}; use base_common_genesis::RollupConfig; use base_consensus_derive::{BlobProvider, EthereumDataSource, Pipeline, SignalReceiver}; use base_proof::{ - BaseExecutor, BootInfo, OracleL1ChainProvider, OracleL2ChainProvider, OraclePipeline, - new_oracle_pipeline_cursor, + BaseExecutor, BootInfo, IntermediateRootSampler, OracleL1ChainProvider, OracleL2ChainProvider, + OraclePipeline, new_oracle_pipeline_cursor, }; use base_proof_driver::{Driver, DriverPipeline, PipelineCursor}; use base_proof_executor::TrieDBProvider; @@ -140,7 +140,8 @@ where /// Run derivation and block execution to produce the proven boot info and derived L2 block. /// - /// Intermediate roots are sampled every [`crate::INTERMEDIATE_ROOT_INTERVAL`] blocks. + /// Intermediate roots are sampled every `boot.intermediate_block_interval` blocks, so the + /// proven range must span a non-zero multiple of that interval. pub async fn run( &self, boot: BootInfo, @@ -178,8 +179,10 @@ where &mut driver, rollup_config.as_ref(), Some(boot.claimed_l2_block_number), + IntermediateRootSampler::new(boot.intermediate_block_interval), ) .await?; + let intermediate_roots = intermediate_roots.finish()?; #[cfg(target_os = "zkvm")] println!("cycle-tracker-report-end: block-execution-and-derivation"); diff --git a/crates/proof/zk/witness/src/fetcher.rs b/crates/proof/zk/witness/src/fetcher.rs index 5e631df9d1..226cc572e1 100644 --- a/crates/proof/zk/witness/src/fetcher.rs +++ b/crates/proof/zk/witness/src/fetcher.rs @@ -1,6 +1,7 @@ use std::{ cmp::{Ordering, min}, env, fmt, fs, + num::NonZeroU64, path::{Path, PathBuf}, str::FromStr, sync::Arc, @@ -750,6 +751,7 @@ impl OPSuccinctDataFetcher { l2_end_block: u64, l1_head_hash: B256, schedule_l2_block_number: Option, + intermediate_block_interval: NonZeroU64, ) -> Result { let Some(rollup_config) = &self.rollup_config else { return Err(anyhow::anyhow!("Rollup config not loaded.")); @@ -851,6 +853,7 @@ impl OPSuccinctDataFetcher { // We don't need to set the proposer for the range proof zk program proposer: Address::ZERO, schedule_l2_block_number, + intermediate_block_interval, }; let prover = base_proof_host::ProverConfig { diff --git a/crates/proof/zk/witness/src/host.rs b/crates/proof/zk/witness/src/host.rs index 68e53055e6..4efd0f3b5b 100644 --- a/crates/proof/zk/witness/src/host.rs +++ b/crates/proof/zk/witness/src/host.rs @@ -1,4 +1,4 @@ -use std::{fmt, sync::Arc}; +use std::{fmt, num::NonZeroU64, sync::Arc}; use alloy_eips::BlockId; use alloy_primitives::B256; @@ -68,9 +68,9 @@ impl SuccinctHost { /// - `l1_head_hash`: Optionally supplied L1 head block hash used as the L1 origin. /// - `safe_db_fallback`: Flag to indicate whether to fallback to timestamp-based L1 head /// estimation when `SafeDB` is not available. - /// - /// Intermediate roots are sampled every [`base_proof_zk_utils::INTERMEDIATE_ROOT_INTERVAL`] - /// blocks. + /// - `schedule_l2_block_number`: L2 block used to pin the upgrade schedule. + /// - `intermediate_block_interval`: Number of blocks between two intermediate roots of the + /// proven game; the range must span a multiple of it. pub async fn fetch( &self, l2_start_block: u64, @@ -78,6 +78,7 @@ impl SuccinctHost { l1_head_hash: Option, safe_db_fallback: bool, schedule_l2_block_number: Option, + intermediate_block_interval: NonZeroU64, ) -> Result { let l1_head_hash = match l1_head_hash { Some(hash) => hash, @@ -85,7 +86,13 @@ impl SuccinctHost { }; self.fetcher - .get_host_args(l2_start_block, l2_end_block, l1_head_hash, schedule_l2_block_number) + .get_host_args( + l2_start_block, + l2_end_block, + l1_head_hash, + schedule_l2_block_number, + intermediate_block_interval, + ) .await } diff --git a/etc/docs-index.toml b/etc/docs-index.toml index 91cf9bfdce..c7531a0e15 100644 --- a/etc/docs-index.toml +++ b/etc/docs-index.toml @@ -60,7 +60,7 @@ digest = "b67d590f7d" ["bin/basectl/README.md"] summary = "basectl operator console reference: global options, TUI and scriptable commands (block, sync-status, p2p, txpool, conductor, sequencer, batcher, doctor, proofs), JSON output and examples." -digest = "dede2cd58a" +digest = "78a08a9f7a" ["bin/builder/README.md"] summary = "Stub README for the base-builder block builder binary, which runs base-builder-core. Minimal, no usage details." @@ -512,7 +512,7 @@ digest = "dc8e161148" ["crates/proof/primitives/README.md"] summary = "base-proof-primitives: shared proof wire types (Proposal, ProofBundle, ProofResult with Tee and Zk variants) exchanged among proposer, enclave, provers and on-chain verifiers." -digest = "274b61e6c7" +digest = "b8cdb98d76" ["crates/proof/proof/README.md"] summary = "base-proof SDK: oracle-backed derivation and execution pieces (OraclePipeline, Oracle L1/L2 providers, CachingOracle, BaseExecutor), boot info and EIP-2935 lookups for fault proof generation." diff --git a/etc/just/zk-prover.just b/etc/just/zk-prover.just index 0c1a93caf2..8a4c92bfe6 100644 --- a/etc/just/zk-prover.just +++ b/etc/just/zk-prover.just @@ -16,7 +16,8 @@ list: @just --justfile {{ source_file() }} methods # Request a proof for a block range: proof_type=compressed|snark zk_backend=dry_run|cluster|network -prove start_block number_of_blocks='1' proof_type='compressed' zk_backend='dry_run' session_id='' prover_address='' l1_head='': +# intermediate_block_interval must divide number_of_blocks; it defaults to number_of_blocks (one root) +prove start_block number_of_blocks='1' proof_type='compressed' zk_backend='dry_run' session_id='' prover_address='' l1_head='' intermediate_block_interval='': #!/usr/bin/env bash set -euo pipefail command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 1; } @@ -31,6 +32,7 @@ prove start_block number_of_blocks='1' proof_type='compressed' zk_backend='dry_r session_id={{ quote(session_id) }} prover_address={{ quote(prover_address) }} l1_head={{ quote(l1_head) }} + intermediate_block_interval={{ quote(intermediate_block_interval) }} case "$start_block" in ''|*[!0-9]*) echo "start_block must be a non-negative integer" >&2; exit 1 ;; @@ -42,6 +44,17 @@ prove start_block number_of_blocks='1' proof_type='compressed' zk_backend='dry_r echo "number_of_blocks must be greater than zero" >&2 exit 1 fi + if [ -z "$intermediate_block_interval" ]; then + intermediate_block_interval="$number_of_blocks" + fi + case "$intermediate_block_interval" in + ''|*[!0-9]*) echo "intermediate_block_interval must be a positive integer" >&2; exit 1 ;; + esac + if [ "$intermediate_block_interval" -eq 0 ] \ + || [ $((number_of_blocks % intermediate_block_interval)) -ne 0 ]; then + echo "intermediate_block_interval must be a nonzero divisor of number_of_blocks" >&2 + exit 1 + fi case "$zk_backend" in dry_run|cluster|network) ;; *) @@ -60,11 +73,13 @@ prove start_block number_of_blocks='1' proof_type='compressed' zk_backend='dry_r block_payload="$(jq -n -c \ --argjson start_block "$start_block" \ --argjson number_of_blocks "$number_of_blocks" \ + --argjson intermediate_block_interval "$intermediate_block_interval" \ --arg l1_head "$l1_head" \ --arg zk_backend "$zk_backend" \ '{ start_block_number: $start_block, number_of_blocks_to_prove: $number_of_blocks, + intermediate_block_interval: $intermediate_block_interval, zk_vm: "sp1", zk_backend: $zk_backend } + if $l1_head == "" then {} else {l1_head: $l1_head} end')" diff --git a/etc/systems/benches/common/zk_proof.rs b/etc/systems/benches/common/zk_proof.rs index 59f7f62109..929e94ebfe 100644 --- a/etc/systems/benches/common/zk_proof.rs +++ b/etc/systems/benches/common/zk_proof.rs @@ -1,6 +1,9 @@ //! ZK proof request helpers for system benchmarks. -use std::time::{Duration, Instant}; +use std::{ + num::NonZeroU64, + time::{Duration, Instant}, +}; use alloy_eips::BlockNumberOrTag; use alloy_primitives::B256; @@ -111,6 +114,9 @@ impl ZkProofBench { .checked_sub(1) .ok_or_else(|| eyre::eyre!("cannot prove genesis block with one-block range"))?; let number_of_blocks_to_prove = last_block_number - first_block_number + 1; + // The workload range is not a dispute game, so commit only its final root. + let intermediate_block_interval = NonZeroU64::new(number_of_blocks_to_prove) + .ok_or_else(|| eyre::eyre!("workload block range is empty"))?; let client_config = ProverServiceClientConfig::new(prover_url.as_str()) .with_request_timeout(Duration::from_secs(30)); let client = ProofRequesterClient::connect(&client_config)?; @@ -125,6 +131,7 @@ impl ZkProofBench { sequence_window: None, l1_head: Some(l1_head), schedule_l2_block_number: None, + intermediate_block_interval, zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::DryRun, }), diff --git a/etc/systems/tests/common/zk_dry_run.rs b/etc/systems/tests/common/zk_dry_run.rs index 3b5ae57594..c3fedce902 100644 --- a/etc/systems/tests/common/zk_dry_run.rs +++ b/etc/systems/tests/common/zk_dry_run.rs @@ -1,6 +1,6 @@ //! Shared SP1 dry-run prove helpers for system tests. -use std::time::Duration; +use std::{num::NonZeroU64, time::Duration}; use alloy_eips::BlockNumberOrTag; use alloy_primitives::B256; @@ -76,6 +76,8 @@ pub(crate) async fn prove_block_range_with_dry_run_stats( sequence_window: None, l1_head: Some(l1_head), schedule_l2_block_number: None, + // A single-block range commits its one output root. + intermediate_block_interval: NonZeroU64::MIN, zk_vm: ZkVm::Sp1, zk_backend: ZkBackend::DryRun, }), From 04485df1eed74b3823e2df94057316d76e71131c Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 19 Oct 2026 09:34:26 -0700 Subject: [PATCH 04/18] [backport] fix(disc): ping unverifiable ENRs so NATed peers can join the table to v1.5.1 (#6064) * [backport] fix(disc): ping unverifiable ENRs so NATed peers can join the table to v1.5.1 (#6025) Squashed cherry-pick of base/base#6025: (cherry picked from commit ef1835ee0a) (cherry picked from commit a37ab29d4a) * fix(disc): verify the actual ping destination Amp-Thread-ID: https://ampcode.com/threads/T-01a14beb-8703-76a8-86fe-4e5a5c08f8f1 Co-authored-by: Amp (cherry picked from commit 6a5356acad6aec99ae1c38e557a1f59c8185e13c) * fix(disc): test verification without IPv6 sockets Amp-Thread-ID: https://ampcode.com/threads/T-01a14beb-8703-76a8-86fe-4e5a5c08f8f1 Co-authored-by: Amp (cherry picked from commit 17ad2497a9b9cf7f2fede13050f0bb34e024efa9) --------- Co-authored-by: Amp --- crates/consensus/disc/src/driver.rs | 186 ++++++++++++++++++++++++++- crates/consensus/disc/src/metrics.rs | 5 +- 2 files changed, 186 insertions(+), 5 deletions(-) diff --git a/crates/consensus/disc/src/driver.rs b/crates/consensus/disc/src/driver.rs index 8c63ff5499..77a2fd9b79 100644 --- a/crates/consensus/disc/src/driver.rs +++ b/crates/consensus/disc/src/driver.rs @@ -1,11 +1,13 @@ //! Discovery Module. +use std::net::SocketAddr; + use backon::{ExponentialBuilder, RetryableWithContext}; use base_consensus_peers::{ BootNode, BootNodes, BootStore, BootStoreFile, EnrValidation, PeerUtils, }; use derive_more::Debug; -use discv5::{Config, Discv5, Enr, enr::NodeId}; +use discv5::{Config, Discv5, Enr, IpMode, enr::NodeId}; use tokio::{ sync::mpsc::{Sender, channel, error::TrySendError}, time::{Duration, sleep}, @@ -97,6 +99,19 @@ impl Discv5Driver { }) } + /// Whether an unverifiable ENR can be pinged without contacting a different IP. + /// + /// Uses the same address-family selection as [`Discv5::send_ping`]. + pub fn should_verify_advertised_socket( + ip_mode: IpMode, + enr: &Enr, + observed: SocketAddr, + ) -> bool { + ip_mode.get_contactable_addr(enr).is_some_and(|advertised| { + advertised.ip().to_canonical() == observed.ip().to_canonical() + }) + } + /// Starts the inner [`Discv5`] service. async fn init(self) -> Result { let (s, res) = { @@ -310,11 +325,33 @@ impl Discv5Driver { store.add_enr(enr); } } - discv5::Event::UnverifiableEnr { enr, .. } + discv5::Event::UnverifiableEnr { enr, socket, .. } if EnrValidation::validate(&enr, chain_id).is_valid() => { debug!(target: "discovery", enr = ?enr, "Valid ENR discovered, forwarding to swarm"); Metrics::discovery_event("unverifiable_enr").increment(1.0); + + // discv5 only adds a node to its table after a session whose source + // socket matches the node's ENR. Peers behind port-translating NAT + // (e.g. K8s pods exposed through a hostPort) fail that check on the + // sessions they start, so they are never handed out to other peers. + // Ping the advertised socket instead: if it answers, discv5 sets up a + // verified session and adds the node to the table. + if Self::should_verify_advertised_socket(self.disc.ip_mode(), &enr, socket) { + let ping = self.disc.send_ping(enr.clone()); + tokio::spawn(async move { + match ping.await { + Ok(_) => { + debug!(target: "discovery", observed = %socket, "Unverifiable ENR answered ping at its advertised socket"); + Metrics::discovery_event("unverifiable_enr_verified").increment(1.0); + } + Err(err) => { + debug!(target: "discovery", observed = %socket, error = ?err, "Unverifiable ENR did not answer ping at its advertised socket"); + } + } + }); + } + if try_forward_enr(&enr_sender, enr.clone()) { store.add_enr(enr); } @@ -378,7 +415,10 @@ impl Discv5Driver { #[cfg(test)] mod tests { - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + time::Instant, + }; use base_common_chains::ChainConfig; use discv5::{ @@ -387,7 +427,7 @@ mod tests { handler::NodeContact, }; use tempfile::tempdir; - use tokio::sync::mpsc; + use tokio::{net::UdpSocket, sync::mpsc}; use super::*; use crate::LocalNode; @@ -580,4 +620,142 @@ mod tests { ); } } + + #[test] + fn verifies_only_the_contactable_advertised_socket() { + let key = CombinedKey::generate_secp256k1(); + let ipv4 = Ipv4Addr::new(192, 0, 2, 1); + let ipv6: Ipv6Addr = "2001:db8::1".parse().unwrap(); + let dual = Enr::builder().ip4(ipv4).udp4(9000).ip6(ipv6).udp6(9001).build(&key).unwrap(); + let v4_only = Enr::builder().ip4(ipv4).udp4(9000).build(&key).unwrap(); + let v6_only = Enr::builder().ip6(ipv6).udp6(9001).build(&key).unwrap(); + let mapped = Enr::builder().ip6(ipv4.to_ipv6_mapped()).udp6(9001).build(&key).unwrap(); + let no_socket = test_enr(); + let observed_v4 = SocketAddr::from((ipv4, 5000)); + let observed_v6 = SocketAddr::from((ipv6, 5001)); + let observed_mapped = SocketAddr::from((ipv4.to_ipv6_mapped(), 5000)); + + for (mode, enr, observed, expected) in [ + (IpMode::Ip4, &dual, observed_v4, true), + (IpMode::Ip4, &dual, observed_v6, false), + (IpMode::Ip6, &dual, observed_v4, false), + (IpMode::Ip6, &dual, observed_v6, true), + (IpMode::DualStack, &dual, observed_v4, false), + (IpMode::DualStack, &dual, observed_v6, true), + (IpMode::DualStack, &v4_only, observed_mapped, true), + (IpMode::DualStack, &v6_only, observed_v6, true), + (IpMode::Ip6, &v4_only, observed_v4, false), + (IpMode::Ip4, &v6_only, observed_v6, false), + (IpMode::Ip6, &mapped, observed_mapped, false), + (IpMode::DualStack, &no_socket, observed_v4, false), + (IpMode::Ip4, &dual, "198.51.100.1:5000".parse().unwrap(), false), + (IpMode::Ip6, &dual, "[2001:db8::2]:5001".parse().unwrap(), false), + ] { + assert_eq!( + Discv5Driver::should_verify_advertised_socket(mode, enr, observed), + expected, + "mode {mode:?}, observed {observed}, enr {enr:?}" + ); + } + } + + /// Reserves a free localhost UDP port. + fn free_udp_port() -> u16 { + std::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port() + } + + /// Forwards UDP like a K8s hostPort: datagrams sent to the returned address reach `target` + /// and its replies leave from the returned address, while packets `target` sends on its own + /// bypass the forwarder and leave from `target`'s socket. + async fn spawn_port_forward(target: SocketAddr) -> SocketAddr { + let public = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let inner = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let public_addr = public.local_addr().unwrap(); + tokio::spawn(async move { + let (mut inbound, mut outbound) = ([0u8; 2048], [0u8; 2048]); + let mut client = None; + loop { + tokio::select! { + Ok((len, from)) = public.recv_from(&mut inbound) => { + client = Some(from); + let _ = inner.send_to(&inbound[..len], target).await; + } + Ok((len, _)) = inner.recv_from(&mut outbound) => { + if let Some(client) = client { + let _ = public.send_to(&outbound[..len], client).await; + } + } + } + } + }); + public_addr + } + + /// Starts a driver listening on `listen_port` whose ENR advertises `advertised_port`. + fn start_driver( + listen_port: u16, + advertised_port: u16, + bootnodes: BootNodes, + bootstore: std::path::PathBuf, + ) -> Discv5Handler { + let CombinedKey::Secp256k1(secret_key) = CombinedKey::generate_secp256k1() else { + unreachable!() + }; + let localhost = IpAddr::V4(Ipv4Addr::LOCALHOST); + let listen = SocketAddr::new(localhost, listen_port); + let (handle, _) = Discv5Driver::builder( + LocalNode::new(secret_key, localhost, advertised_port, advertised_port), + TEST_CHAIN_ID, + ConfigBuilder::new(listen.into()).build(), + ) + .with_bootnodes(bootnodes) + .with_bootstore_file(Some(BootStoreFile::Custom(bootstore))) + .with_interval(Duration::from_millis(500)) + .build() + .expect("Failed to build discovery service") + .start(); + handle + } + + /// Chain ID with no hardcoded bootnodes, so the test never leaves localhost. + const TEST_CHAIN_ID: u64 = 7_777_777; + + #[tokio::test] + async fn admits_peer_whose_packets_leave_from_a_translated_port() { + let dir = tempdir().unwrap(); + + // Observer, e.g. a bootnode, with a plain socket. + let observer_port = free_udp_port(); + let observer = start_driver( + observer_port, + observer_port, + BootNodes(vec![]), + dir.path().join("a.json"), + ); + let observer_enr = observer.local_enr().await.unwrap(); + + // Peer behind a hostPort-style forward: it advertises the forwarded port, but the + // sessions it starts come from its own listen port, so the observer cannot verify + // its ENR from those sessions. + let peer_port = free_udp_port(); + let forwarded = + spawn_port_forward(SocketAddr::from((Ipv4Addr::LOCALHOST, peer_port))).await; + let peer = start_driver( + peer_port, + forwarded.port(), + BootNodes(vec![BootNode::Enr(observer_enr)]), + dir.path().join("b.json"), + ); + let peer_id = peer.local_enr().await.unwrap().node_id(); + + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let table = observer.table_enrs().await.unwrap(); + if table.iter().any(|enr| enr.node_id() == peer_id) { + break; + } + assert!(Instant::now() < deadline, "peer never entered the observer's table"); + sleep(Duration::from_millis(200)).await; + } + } } diff --git a/crates/consensus/disc/src/metrics.rs b/crates/consensus/disc/src/metrics.rs index 83586c7826..f30a9e63c2 100644 --- a/crates/consensus/disc/src/metrics.rs +++ b/crates/consensus/disc/src/metrics.rs @@ -4,7 +4,10 @@ base_metrics::define_metrics! { base_node_disc #[describe("Events received by the discv5 service")] - #[label(name = "type", default = ["discovered", "session_established", "unverifiable_enr"])] + #[label( + name = "type", + default = ["discovered", "session_established", "unverifiable_enr", "unverifiable_enr_verified"] + )] discovery_event: gauge, #[describe("ENRs dropped before forwarding to the gossip service")] From ef1e25f589b081b7f6bba6be51a6e1e1e42d9b72 Mon Sep 17 00:00:00 2001 From: Fahim Ahmed Date: Mon, 19 Oct 2026 10:59:02 -0700 Subject: [PATCH 05/18] refactor(txpool): partition validity conditions after ingestion (#5669) (#6102) * refactor(txpool): partition validity conditions after ingestion * fix(txpool): borrow state predicates during validity checks * fix(txpool): require compiled validity conditions * perf(validity): evaluate compiled bounds directly (cherry picked from commit 02550c5c340534b42484710061139366d0e39b72) --- crates/builder/core/benches/tx_selection.rs | 5 +- .../builder/core/src/flashblocks/best_txs.rs | 12 +- .../builder/core/src/flashblocks/context.rs | 24 +- .../builder/core/src/flashblocks/resting.rs | 4 +- crates/execution/payload/src/builder.rs | 21 +- crates/execution/payload/src/lib.rs | 2 + .../execution/payload/src/predicate_loads.rs | 10 +- crates/execution/payload/src/validity.rs | 350 ++++++++- crates/execution/txpool/src/conditions.rs | 670 ++++++++++++++++++ crates/execution/txpool/src/lib.rs | 3 + crates/execution/txpool/src/ordering.rs | 21 +- crates/execution/txpool/src/pool.rs | 17 +- crates/execution/txpool/src/transaction.rs | 97 ++- crates/execution/txpool/src/validity.rs | 73 +- 14 files changed, 1149 insertions(+), 160 deletions(-) create mode 100644 crates/execution/txpool/src/conditions.rs diff --git a/crates/builder/core/benches/tx_selection.rs b/crates/builder/core/benches/tx_selection.rs index d047bf51b9..3612775b84 100644 --- a/crates/builder/core/benches/tx_selection.rs +++ b/crates/builder/core/benches/tx_selection.rs @@ -251,14 +251,13 @@ fn run_predicate_selection(pool: &Pool, db: &mut InMemoryDB) -> usize { while let Some(transaction) = best.next(()) { let blocking_predicate = ValidityPredicateKey::first_unsatisfied( - transaction.validity_predicates(), + transaction.validity_conditions(), db, &context, ) .expect("in-memory reads cannot fail"); - if let Some((blocking_predicate_index, _)) = blocking_predicate { + if let Some((predicate, _)) = blocking_predicate { let transaction_hash = *transaction.hash(); - let predicate = transaction.validity_predicates()[blocking_predicate_index].clone(); best.park_current(); predicate_index.park(transaction_hash, transaction, predicate); continue; diff --git a/crates/builder/core/src/flashblocks/best_txs.rs b/crates/builder/core/src/flashblocks/best_txs.rs index 561e72905e..b2cffd5c46 100644 --- a/crates/builder/core/src/flashblocks/best_txs.rs +++ b/crates/builder/core/src/flashblocks/best_txs.rs @@ -4,7 +4,7 @@ use std::{collections::HashSet, marker::PhantomData, time::Instant}; use alloy_primitives::{Address, TxHash, map::B256Set}; use base_execution_payload_builder::{ParkablePayloadTransactions, ParkedPredicateIndex}; -use base_execution_txpool::{BasePooledTx, ValidityPredicate}; +use base_execution_txpool::{BasePooledTx, ValidityConditions, ValidityPredicate}; use reth_payload_util::PayloadTransactions; use revm::state::EvmState; @@ -137,10 +137,10 @@ where if self.resting_predicate_mode.is_enforced() && !self.resting.is_empty() - && !tx.validity_predicates().is_empty() + && !tx.validity_conditions().is_empty() { let started = Instant::now(); - let resting = self.is_resting(hash, tx.validity_predicates()); + let resting = self.is_resting(hash, tx.validity_conditions()); if resting { self.inner.park_current(); self.parked_resting.insert(hash); @@ -228,7 +228,7 @@ where /// A hash re-added to the pool with a batch that no longer contains the recorded predicate /// does not rest. - fn is_resting(&self, transaction_hash: TxHash, predicates: &[ValidityPredicate]) -> bool { + fn is_resting(&self, transaction_hash: TxHash, predicates: &ValidityConditions) -> bool { self.resting.predicate(transaction_hash).is_some_and(|blocker| predicates.contains(blocker)) } @@ -807,7 +807,7 @@ mod tests { iterator.refresh_iterator(parkable(&pool)); let yielded = iterator.next(()).unwrap(); assert_eq!(*yielded.hash(), *resting.hash()); - assert!(iterator.is_resting(*resting.hash(), yielded.validity_predicates())); + assert!(iterator.is_resting(*resting.hash(), yielded.validity_conditions())); } #[test] @@ -821,6 +821,6 @@ mod tests { iterator.rest(*resting.hash(), &predicates[0]); - assert!(!iterator.is_resting(*resting.hash(), predicates)); + assert!(!iterator.is_resting(*resting.hash(), resting.transaction.validity_conditions())); } } diff --git a/crates/builder/core/src/flashblocks/context.rs b/crates/builder/core/src/flashblocks/context.rs index cc9ea9e4f1..438b686cd1 100644 --- a/crates/builder/core/src/flashblocks/context.rs +++ b/crates/builder/core/src/flashblocks/context.rs @@ -796,7 +796,7 @@ impl BasePayloadBuilderCtx { let ordering_position = num_txs_considered; let tx_hash = *tx.hash(); let replay_independent = tx.eip8130_replay_id().is_some(); - let has_validity_predicates = !tx.validity_predicates().is_empty(); + let has_validity_predicates = !tx.validity_conditions().is_empty(); let validity_handling_start = has_validity_predicates.then(Instant::now); let coinbase_tip = tx.as_eip8130().and_then(|signed| CoinbaseTip::decode(signed.tx())); let has_coinbase_tip = coinbase_tip.is_some(); @@ -839,7 +839,7 @@ impl BasePayloadBuilderCtx { let mut recorder = PredicateReadRecorder::new(&mut **evm.db_mut(), &mut info.predicate_loads); ValidityPredicateEvaluation::evaluate( - tx.validity_predicates(), + tx.validity_conditions(), &mut recorder, &predicate_context, ) @@ -847,11 +847,11 @@ impl BasePayloadBuilderCtx { Ok(ValidityPredicateEvaluation::Matched) => None, Ok(ValidityPredicateEvaluation::Unsatisfied { blocker, - blocker_index, + predicate, expired, }) => { predicate_expired = expired; - Some((blocker, blocker_index)) + Some((blocker, predicate)) } Err(error) => { warn!( @@ -876,7 +876,7 @@ impl BasePayloadBuilderCtx { "matched" }; ValidityMetrics::validity_predicate_evaluations_total(outcome).increment(1); - if outcome == "matched" && best_txs.is_resting(tx_hash, tx.validity_predicates()) { + if outcome == "matched" && best_txs.is_resting(tx_hash, tx.validity_conditions()) { BuilderMetrics::resting_predicate_shadow_mismatches_total().increment(1); debug!( target: "payload_builder", @@ -926,7 +926,7 @@ impl BasePayloadBuilderCtx { } else { // State mismatch: retry at a later position or flashblock. Passed nonce // bounds also stay parked until the required block-number expiry. - let (_, blocker_index) = blocking_predicate + let (_, predicate) = blocking_predicate .expect("unsatisfied, non-terminal predicate implies a blocking key"); self.defer_current( best_txs, @@ -936,7 +936,6 @@ impl BasePayloadBuilderCtx { tx_hash, ordering_position, ); - let predicate = tx.validity_predicates()[blocker_index].clone(); best_txs.rest(tx_hash, &predicate); predicate_index.park(tx_hash, tx, predicate); } @@ -1445,17 +1444,15 @@ impl BasePayloadBuilderCtx { &mut info.predicate_loads, ); ValidityPredicateEvaluation::evaluate( - parked_transaction.validity_predicates(), + parked_transaction.validity_conditions(), &mut recorder, &predicate_context, ) }) { Ok(ValidityPredicateEvaluation::Matched) => None, Ok(ValidityPredicateEvaluation::Unsatisfied { - blocker, - blocker_index, - .. - }) => Some((blocker, blocker_index)), + blocker, predicate, .. + }) => Some((blocker, predicate)), Err(error) => { warn!( target: "payload_builder", @@ -1478,8 +1475,7 @@ impl BasePayloadBuilderCtx { if predicate_read_failed { predicate_index.remove(*parked_hash); best_txs.discard_parked(*parked_hash); - } else if let Some((_, blocker_index)) = blocking_predicate { - let predicate = parked_transaction.validity_predicates()[blocker_index].clone(); + } else if let Some((_, predicate)) = blocking_predicate { best_txs.rest(*parked_hash, &predicate); predicate_index.reindex(*parked_hash, predicate); } else { diff --git a/crates/builder/core/src/flashblocks/resting.rs b/crates/builder/core/src/flashblocks/resting.rs index d938020bdd..30d59b90dd 100644 --- a/crates/builder/core/src/flashblocks/resting.rs +++ b/crates/builder/core/src/flashblocks/resting.rs @@ -3,7 +3,7 @@ use std::time::Duration; use alloy_primitives::TxHash; -use base_execution_txpool::ValidityPredicate; +use base_execution_txpool::{ValidityConditions, ValidityPredicate}; use revm::state::EvmState; /// Work an iterator spent holding back resting transactions since it was last asked. @@ -30,7 +30,7 @@ pub trait RestingPayloadTransactions { fn record_committed_state(&mut self, _state: &EvmState) {} /// Returns whether a transaction with `predicates` rests under an unchanged predicate. - fn is_resting(&self, _transaction_hash: TxHash, _predicates: &[ValidityPredicate]) -> bool { + fn is_resting(&self, _transaction_hash: TxHash, _predicates: &ValidityConditions) -> bool { false } diff --git a/crates/execution/payload/src/builder.rs b/crates/execution/payload/src/builder.rs index cbd23c4cd2..bb79d9727c 100644 --- a/crates/execution/payload/src/builder.rs +++ b/crates/execution/payload/src/builder.rs @@ -18,7 +18,7 @@ use base_common_consensus::{BaseTransaction, CoinbaseTip, Predeploys}; use base_common_evm::L1BlockInfo; use base_execution_eip8130::IntrinsicGas; use base_execution_txpool::{ - BasePooledTx, GuardMetrics, ParkableTransactionPool, PredicateContext, ValidityPredicate, + BasePooledTx, GuardMetrics, ParkableTransactionPool, PredicateContext, estimated_da_size::DataAvailabilitySized, }; use base_observability_events::{ @@ -950,7 +950,7 @@ where continue; } - let has_validity_predicates = !tx.validity_predicates().is_empty(); + let has_validity_predicates = !tx.validity_conditions().is_empty(); let coinbase_tip = tx.as_eip8130().and_then(|signed| CoinbaseTip::decode(signed.tx())); let has_coinbase_tip = coinbase_tip.is_some(); // Every validity candidate ends with a decision event, so there is no separate @@ -958,11 +958,7 @@ where if has_validity_predicates { validity_consideration_index += 1; } - if tx - .validity_predicates() - .iter() - .any(|predicate| matches!(predicate, ValidityPredicate::FlashblockIndex { .. })) - { + if tx.validity_conditions().flashblock().is_present() { ValidityMetrics::validity_predicate_evaluations_total("unsupported").increment(1); emit_native_validity_event!( self, @@ -1024,7 +1020,7 @@ where &mut predicate_loads, ); ValidityPredicateEvaluation::evaluate( - tx.validity_predicates(), + tx.validity_conditions(), &mut recorder, &predicate_context, ) @@ -1064,7 +1060,7 @@ where } Ok(ValidityPredicateEvaluation::Unsatisfied { blocker, - blocker_index, + predicate, expired: false, }) => { ValidityMetrics::validity_predicate_evaluations_total("not_satisfied") @@ -1088,7 +1084,6 @@ where } ); } - let predicate = tx.validity_predicates()[blocker_index].clone(); predicate_index.park(tx_hash, tx, predicate); continue; } @@ -1420,7 +1415,7 @@ where &mut predicate_loads, ); ValidityPredicateEvaluation::evaluate( - parked_transaction.validity_predicates(), + parked_transaction.validity_conditions(), &mut recorder, &predicate_context, ) @@ -1434,13 +1429,11 @@ where predicate_index.remove(parked_hash); best_txs.promote(parked_hash); } - Ok(ValidityPredicateEvaluation::Unsatisfied { blocker_index, .. }) => { + Ok(ValidityPredicateEvaluation::Unsatisfied { predicate, .. }) => { ValidityMetrics::validity_predicate_evaluations_total( "rescan_not_satisfied", ) .increment(1); - let predicate = - parked_transaction.validity_predicates()[blocker_index].clone(); predicate_index.reindex(parked_hash, predicate); } Err(error) => { diff --git a/crates/execution/payload/src/lib.rs b/crates/execution/payload/src/lib.rs index f45ccf3611..3dd57ae6c2 100644 --- a/crates/execution/payload/src/lib.rs +++ b/crates/execution/payload/src/lib.rs @@ -55,6 +55,8 @@ mod types; pub use types::BasePayloadTypes; mod validity; +#[cfg(test)] +pub use validity::test_utils; pub use validity::{ DEFAULT_PREDICATE_BUCKET_ORDERED_THRESHOLD, ParkedPredicateIndex, StateChangeEffects, ValidityPredicateEvaluation, ValidityPredicateKey, diff --git a/crates/execution/payload/src/predicate_loads.rs b/crates/execution/payload/src/predicate_loads.rs index 5000a16ae0..d65a095886 100644 --- a/crates/execution/payload/src/predicate_loads.rs +++ b/crates/execution/payload/src/predicate_loads.rs @@ -135,7 +135,9 @@ impl Database for PredicateReadRecorder<'_, DB> { #[cfg(test)] mod tests { use alloy_primitives::{Address, U256}; - use base_execution_txpool::{PredicateContext, ValidityOperator, ValidityPredicate}; + use base_execution_txpool::{ + PredicateContext, ValidityConditions, ValidityOperator, ValidityPredicate, + }; use reth_revm::State; use revm::{database::InMemoryDB, state::AccountInfo}; @@ -180,7 +182,7 @@ mod tests { }; let nonce = ValidityPredicate::Nonce { address, op: ValidityOperator::Equal, value: U256::from(2) }; - let predicates = [balance, nonce, storage]; + let predicates = ValidityConditions::new(vec![balance, nonce, storage]); // Evaluate the same batch twice: totals double, unique counts do not. for _ in 0..2 { @@ -218,14 +220,14 @@ mod tests { op: ValidityOperator::Equal, value: U256::from(3), }; - let predicates = [failing_balance, storage]; + let predicates = ValidityConditions::new(vec![failing_balance.clone(), storage]); { let mut recorder = PredicateReadRecorder::new(&mut state, &mut tracker); assert_eq!( ValidityPredicateKey::first_unsatisfied(&predicates, &mut recorder, &context()) .unwrap(), - Some((0, ValidityPredicateKey::Balance(address))) + Some((failing_balance, ValidityPredicateKey::Balance(address))) ); } diff --git a/crates/execution/payload/src/validity.rs b/crates/execution/payload/src/validity.rs index ff457a193f..b1e847db7c 100644 --- a/crates/execution/payload/src/validity.rs +++ b/crates/execution/payload/src/validity.rs @@ -7,7 +7,9 @@ use alloy_primitives::{ Address, TxHash, U256, map::{B256Map, B256Set, HashMap, U256Map}, }; -use base_execution_txpool::{PredicateContext, ValidityOperator, ValidityPredicate}; +use base_execution_txpool::{ + PredicateContext, ValidityConditions, ValidityOperator, ValidityPredicate, +}; use revm::{Database, state::EvmState}; /// The number of parked predicates at which a flat bucket becomes ordered by default. @@ -45,17 +47,30 @@ impl ValidityPredicateKey { } } - /// Returns the first predicate that does not hold against `db` and `context`, with its index. + /// Returns the first unsatisfied normalized predicate and its state or context key. + /// + /// State predicates are borrowed during evaluation and cloned only when unsatisfied. pub fn first_unsatisfied( - predicates: &[ValidityPredicate], + predicates: &ValidityConditions, db: &mut DB, context: &PredicateContext, - ) -> Result, DB::Error> { - for (index, predicate) in predicates.iter().enumerate() { - match predicate.matches(db, context) { - Ok(true) => {} - Ok(false) => return Ok(Some((index, Self::for_predicate(predicate)))), - Err(error) => return Err(error), + ) -> Result, DB::Error> { + if let Some((op, value)) = + predicates.block().first_unsatisfied(U256::from(context.block_number)) + { + return Ok(Some((ValidityPredicate::BlockNumber { op, value }, Self::BlockNumber))); + } + if let Some((op, value)) = + predicates.flashblock().first_unsatisfied(U256::from(context.flashblock_index)) + { + return Ok(Some(( + ValidityPredicate::FlashblockIndex { op, value }, + Self::FlashblockIndex, + ))); + } + for predicate in predicates.state_predicates() { + if !predicate.matches(db, context)? { + return Ok(Some((predicate.clone(), Self::for_predicate(predicate)))); } } Ok(None) @@ -63,7 +78,7 @@ impl ValidityPredicateKey { } /// Result of evaluating a transaction's validity predicates at one build position. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ValidityPredicateEvaluation { /// Every predicate is satisfied. Matched, @@ -71,8 +86,8 @@ pub enum ValidityPredicateEvaluation { Unsatisfied { /// State or position key that currently blocks the transaction. blocker: ValidityPredicateKey, - /// Position of the failed predicate in the submitted batch. - blocker_index: usize, + /// Failed condition, returned directly so parking does not rescan the batch. + predicate: ValidityPredicate, /// Whether the predicate batch can never be satisfied at a later build position. expired: bool, }, @@ -81,20 +96,16 @@ pub enum ValidityPredicateEvaluation { impl ValidityPredicateEvaluation { /// Evaluates predicates against the current state and build position. pub fn evaluate( - predicates: &[ValidityPredicate], + predicates: &ValidityConditions, db: &mut DB, context: &PredicateContext, ) -> Result { - let Some((blocker_index, blocker)) = + let Some((predicate, blocker)) = ValidityPredicateKey::first_unsatisfied(predicates, db, context)? else { return Ok(Self::Matched); }; - Ok(Self::Unsatisfied { - blocker, - blocker_index, - expired: ValidityPredicate::is_batch_expired(predicates, context), - }) + Ok(Self::Unsatisfied { blocker, predicate, expired: predicates.is_expired(context) }) } } @@ -520,17 +531,82 @@ pub struct StateChangeEffects { pub woken_buckets: usize, } +#[cfg(test)] +pub mod test_utils { + //! Hand-rolled evaluation database for a single ordered log spanning account and + //! storage reads, including reads that fail. Separate method mocks cannot express + //! this cross-method log. + + use alloy_primitives::{Address, B256, U256}; + use revm::{ + Database, + database::InMemoryDB, + database_interface::DBErrorMarker, + state::{AccountInfo, Bytecode}, + }; + + /// A scripted account or storage read failure. + #[derive(Debug, PartialEq, Eq, thiserror::Error)] + #[error("scripted state read failure")] + pub struct ReadError; + + impl DBErrorMarker for ReadError {} + + /// In-memory state with ordered account/slot reads and optional error injection. + #[derive(Debug, Default)] + pub struct RecordingDatabase { + /// State returned by successful reads. + pub inner: InMemoryDB, + /// Account reads have no slot; storage reads retain their slot. + pub reads: Vec<(Address, Option)>, + /// One-based read index at which to return an error. + pub fail_on: Option, + } + + impl Database for RecordingDatabase { + type Error = ReadError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + self.reads.push((address, None)); + if self.fail_on == Some(self.reads.len()) { + return Err(ReadError); + } + Ok(self.inner.basic(address).unwrap()) + } + + fn storage(&mut self, address: Address, slot: U256) -> Result { + self.reads.push((address, Some(slot))); + if self.fail_on == Some(self.reads.len()) { + return Err(ReadError); + } + Ok(self.inner.storage(address, slot).unwrap()) + } + + fn code_by_hash(&mut self, _: B256) -> Result { + panic!("validity predicates must not load bytecode"); + } + + fn block_hash(&mut self, _: u64) -> Result { + panic!("validity predicates must not load block hashes"); + } + } +} + #[cfg(test)] mod tests { use alloy_primitives::{Address, B256, U256, map::B256Set}; - use base_execution_txpool::{PredicateContext, ValidityOperator, ValidityPredicate}; + use base_execution_txpool::{ + PredicateContext, ValidityConditions, ValidityOperator, ValidityPredicate, + }; use revm::{ database::InMemoryDB, state::{Account, AccountInfo, EvmState, EvmStorageSlot}, }; use super::{ - ParkedPredicateIndex, StateChangeEffects, ValidityPredicateEvaluation, ValidityPredicateKey, + ParkedPredicateIndex, StateChangeEffects, ValidityPredicateEvaluation, + ValidityPredicateKey, + test_utils::{ReadError, RecordingDatabase}, }; fn balance(address: Address, op: ValidityOperator, value: u64) -> ValidityPredicate { @@ -568,6 +644,228 @@ mod tests { EvmState::from_iter([(address, account)]) } + #[test] + fn state_partition_order_short_circuits_failures_and_propagates_errors() { + let first = Address::with_last_byte(1); + let second = Address::with_last_byte(2); + let nonce_address = Address::with_last_byte(3); + let storage_address = Address::with_last_byte(4); + let ordered = [ + balance(first, ValidityOperator::Equal, 1), + balance(second, ValidityOperator::Equal, 2), + ValidityPredicate::Nonce { + address: nonce_address, + op: ValidityOperator::Equal, + value: U256::from(3), + }, + storage(storage_address, U256::from(0xff), ValidityOperator::Equal, 4), + ]; + let conditions = ValidityConditions::new(vec![ + ordered[3].clone(), + ordered[2].clone(), + ordered[0].clone(), + ordered[1].clone(), + ]); + let context = PredicateContext { block_number: 100, flashblock_index: 2 }; + let expected_reads = [ + (first, None), + (second, None), + (nonce_address, None), + (storage_address, Some(U256::from(7))), + ]; + let mut db = RecordingDatabase::default(); + for (index, predicate) in ordered.iter().enumerate() { + db.reads.clear(); + let blocker = match index { + 0 => ValidityPredicateKey::Balance(first), + 1 => ValidityPredicateKey::Balance(second), + 2 => ValidityPredicateKey::Nonce(nonce_address), + _ => ValidityPredicateKey::Storage(storage_address, U256::from(7)), + }; + assert_eq!( + ValidityPredicateEvaluation::evaluate(&conditions, &mut db, &context), + Ok(ValidityPredicateEvaluation::Unsatisfied { + blocker, + predicate: predicate.clone(), + expired: false + }) + ); + assert!(conditions.contains(predicate)); + assert_eq!(db.reads, expected_reads[..=index]); + match index { + 0 => db.inner.insert_account_info( + first, + AccountInfo { balance: U256::ONE, ..Default::default() }, + ), + 1 => db.inner.insert_account_info( + second, + AccountInfo { balance: U256::from(2), ..Default::default() }, + ), + 2 => db.inner.insert_account_info( + nonce_address, + AccountInfo { nonce: 3, ..Default::default() }, + ), + _ => db + .inner + .insert_account_storage(storage_address, U256::from(7), U256::from(0x104)) + .unwrap(), + } + } + db.reads.clear(); + assert_eq!( + ValidityPredicateEvaluation::evaluate(&conditions, &mut db, &context), + Ok(ValidityPredicateEvaluation::Matched) + ); + assert_eq!(db.reads, expected_reads); + for failing_read in 1..=4 { + db.reads.clear(); + db.fail_on = Some(failing_read); + assert_eq!( + ValidityPredicateEvaluation::evaluate(&conditions, &mut db, &context), + Err(ReadError) + ); + assert_eq!(db.reads, expected_reads[..failing_read]); + } + } + + #[test] + fn timing_blockers_are_normalized_and_gate_even_failing_state_reads() { + let state = balance(Address::ZERO, ValidityOperator::Equal, 1); + let conditions = ValidityConditions::new(vec![ + state, + ValidityPredicate::FlashblockIndex { + op: ValidityOperator::Equal, + value: U256::from(2), + }, + ValidityPredicate::BlockNumber { + op: ValidityOperator::GreaterThan, + value: U256::from(9), + }, + ValidityPredicate::BlockNumber { + op: ValidityOperator::LessThan, + value: U256::from(21), + }, + ValidityPredicate::BlockNumber { + op: ValidityOperator::NotEqual, + value: U256::from(15), + }, + ValidityPredicate::BlockNumber { + op: ValidityOperator::NotEqual, + value: U256::from(15), + }, + ]); + for (block_number, flashblock_index, blocker, op, value, expired) in [ + ( + 9, + 1, + ValidityPredicateKey::BlockNumber, + ValidityOperator::GreaterThanOrEqual, + 10, + false, + ), + (15, 1, ValidityPredicateKey::BlockNumber, ValidityOperator::NotEqual, 15, false), + (21, 1, ValidityPredicateKey::BlockNumber, ValidityOperator::LessThanOrEqual, 20, true), + ( + 10, + 1, + ValidityPredicateKey::FlashblockIndex, + ValidityOperator::GreaterThanOrEqual, + 2, + false, + ), + ( + 10, + 3, + ValidityPredicateKey::FlashblockIndex, + ValidityOperator::LessThanOrEqual, + 2, + false, + ), + ( + 20, + 3, + ValidityPredicateKey::FlashblockIndex, + ValidityOperator::LessThanOrEqual, + 2, + true, + ), + // A later block resets the flashblock index, so a missed flashblock is not terminal. + ( + 11, + 1, + ValidityPredicateKey::FlashblockIndex, + ValidityOperator::GreaterThanOrEqual, + 2, + false, + ), + ] { + let context = PredicateContext { block_number, flashblock_index }; + let predicate = match blocker { + ValidityPredicateKey::BlockNumber => { + ValidityPredicate::BlockNumber { op, value: U256::from(value) } + } + _ => ValidityPredicate::FlashblockIndex { op, value: U256::from(value) }, + }; + let mut db = RecordingDatabase { fail_on: Some(1), ..Default::default() }; + assert_eq!( + ValidityPredicateEvaluation::evaluate(&conditions, &mut db, &context), + Ok(ValidityPredicateEvaluation::Unsatisfied { + blocker, + predicate: predicate.clone(), + expired + }) + ); + assert!(conditions.contains(&predicate)); + assert!(db.reads.is_empty()); + } + let mut db = RecordingDatabase { fail_on: Some(1), ..Default::default() }; + let context = PredicateContext { block_number: 11, flashblock_index: 2 }; + assert_eq!( + ValidityPredicateEvaluation::evaluate(&conditions, &mut db, &context), + Err(ReadError) + ); + assert_eq!(db.reads, [(Address::ZERO, None)]); + db.reads.clear(); + assert_eq!( + ValidityPredicateEvaluation::evaluate(ValidityConditions::empty(), &mut db, &context), + Ok(ValidityPredicateEvaluation::Matched) + ); + assert!(db.reads.is_empty()); + } + + #[test] + fn timing_mismatch_parks_without_reading_state() { + let address = Address::with_last_byte(1); + let conditions = ValidityConditions::new(vec![ + balance(address, ValidityOperator::GreaterThan, 0), + ValidityPredicate::BlockNumber { + op: ValidityOperator::GreaterThanOrEqual, + value: U256::from(101), + }, + ]); + let context = PredicateContext { block_number: 100, flashblock_index: 1 }; + let mut db = InMemoryDB::default(); + assert_eq!( + ValidityPredicateEvaluation::evaluate(&conditions, &mut db, &context).unwrap(), + ValidityPredicateEvaluation::Unsatisfied { + blocker: ValidityPredicateKey::BlockNumber, + predicate: ValidityPredicate::BlockNumber { + op: ValidityOperator::GreaterThanOrEqual, + value: U256::from(101) + }, + expired: false, + }, + ); + assert!(db.cache.accounts.is_empty(), "timing gate must not load account state"); + let next = PredicateContext { block_number: 101, ..context }; + let evaluation = + ValidityPredicateEvaluation::evaluate(&conditions, &mut db, &next).unwrap(); + assert!( + matches!(evaluation, ValidityPredicateEvaluation::Unsatisfied { blocker: ValidityPredicateKey::Balance(found), .. } if found == address) + ); + assert!(db.cache.accounts.contains_key(&address)); + } + #[test] fn passed_nonce_upper_bounds_remain_ineligible_until_block_expiry() { let address = Address::with_last_byte(1); @@ -584,6 +882,7 @@ mod tests { value: U256::from(102), }, ]; + let predicates = ValidityConditions::new(predicates.to_vec()); let mut db = InMemoryDB::default(); db.insert_account_info( address, @@ -598,7 +897,7 @@ mod tests { ValidityPredicateEvaluation::evaluate(&predicates, &mut db, &context).unwrap(), ValidityPredicateEvaluation::Unsatisfied { blocker: ValidityPredicateKey::Nonce(address), - blocker_index: 0, + predicate: ValidityPredicate::Nonce { address, op, value: U256::from(3) }, expired: false, }, ); @@ -607,8 +906,11 @@ mod tests { ValidityPredicateEvaluation::evaluate(&predicates, &mut db, &expired_context) .unwrap(), ValidityPredicateEvaluation::Unsatisfied { - blocker: ValidityPredicateKey::Nonce(address), - blocker_index: 0, + blocker: ValidityPredicateKey::BlockNumber, + predicate: ValidityPredicate::BlockNumber { + op: ValidityOperator::LessThanOrEqual, + value: U256::from(102) + }, expired: true, }, ); diff --git a/crates/execution/txpool/src/conditions.rs b/crates/execution/txpool/src/conditions.rs new file mode 100644 index 0000000000..8782b42c35 --- /dev/null +++ b/crates/execution/txpool/src/conditions.rs @@ -0,0 +1,670 @@ +//! Ingested validity conditions, partitioned into timing bounds and state predicates. + +use std::{borrow::Cow, sync::OnceLock}; + +use alloy_primitives::U256; + +use crate::{PredicateContext, ValidityOperator, ValidityPredicate}; + +/// Inclusive timing bounds compiled once from a conjunction of predicates. +/// +/// `!=` cannot be represented by min/max bounds and remains an explicit exclusion. +/// Strict comparisons at the ends of the U256 domain remain unsatisfiable rather +/// than wrapping or becoming a satisfiable inclusive bound. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ValidityBounds { + min: Option, + max: Option, + excluded: Vec, + below_zero: bool, + above_max: bool, + present: bool, +} + +impl ValidityBounds { + /// Adds one timing comparison to this conjunction. + pub fn insert(&mut self, op: ValidityOperator, value: U256) { + self.present = true; + match op { + ValidityOperator::LessThan => match value.checked_sub(U256::from(1)) { + Some(max) => self.max = Some(self.max.map_or(max, |old| old.min(max))), + None => self.below_zero = true, + }, + ValidityOperator::LessThanOrEqual => { + self.max = Some(self.max.map_or(value, |old| old.min(value))); + } + ValidityOperator::Equal => { + self.min = Some(self.min.map_or(value, |old| old.max(value))); + self.max = Some(self.max.map_or(value, |old| old.min(value))); + } + ValidityOperator::NotEqual => self.excluded.push(value), + ValidityOperator::GreaterThan => match value.checked_add(U256::from(1)) { + Some(min) => self.min = Some(self.min.map_or(min, |old| old.max(min))), + None => self.above_max = true, + }, + ValidityOperator::GreaterThanOrEqual => { + self.min = Some(self.min.map_or(value, |old| old.max(value))); + } + } + } + + /// Returns whether any predicate for this timing target was submitted. + pub const fn is_present(&self) -> bool { + self.present + } + + /// Returns the tightest inclusive lower bound. + pub const fn min(&self) -> Option { + self.min + } + + /// Returns the tightest inclusive upper bound. + pub const fn max(&self) -> Option { + self.max + } + + /// Returns the first failed normalized comparison, without materializing predicates. + /// + /// Impossible strict endpoints precede the lower bound, upper bound, and exclusions. + pub fn first_unsatisfied(&self, value: U256) -> Option<(ValidityOperator, U256)> { + if self.below_zero { + return Some((ValidityOperator::LessThan, U256::ZERO)); + } + if self.above_max { + return Some((ValidityOperator::GreaterThan, U256::MAX)); + } + if let Some(min) = self.min + && value < min + { + return Some((ValidityOperator::GreaterThanOrEqual, min)); + } + if let Some(max) = self.max + && value > max + { + return Some((ValidityOperator::LessThanOrEqual, max)); + } + self.excluded.contains(&value).then_some((ValidityOperator::NotEqual, value)) + } + + /// Whether a comparison is part of this conjunction's normalized representation. + /// + /// This checks blocker identity, not whether the comparison is implied by the bounds. + pub fn contains(&self, op: ValidityOperator, value: U256) -> bool { + match op { + ValidityOperator::LessThan => self.below_zero && value == U256::ZERO, + ValidityOperator::GreaterThan => self.above_max && value == U256::MAX, + ValidityOperator::GreaterThanOrEqual => self.min == Some(value), + ValidityOperator::LessThanOrEqual => self.max == Some(value), + ValidityOperator::NotEqual => self.excluded.contains(&value), + ValidityOperator::Equal => false, + } + } + + /// Returns comparisons equivalent to the ingested conjunction. + pub fn comparisons(&self) -> impl Iterator + '_ { + self.below_zero + .then_some((ValidityOperator::LessThan, U256::ZERO)) + .into_iter() + .chain(self.above_max.then_some((ValidityOperator::GreaterThan, U256::MAX))) + .chain(self.min.map(|min| (ValidityOperator::GreaterThanOrEqual, min))) + .chain(self.max.map(|max| (ValidityOperator::LessThanOrEqual, max))) + .chain(self.excluded.iter().map(|value| (ValidityOperator::NotEqual, *value))) + } + + /// Returns the pool's finite, inclusive expiry bound without rescanning predicates. + pub fn expiry_bound(&self) -> Option { + if self.below_zero { + return Some(0); + } + self.max.and_then(|max| u64::try_from(max).ok()) + } + + /// Heap bytes used by exclusions. + pub const fn heap_size(&self) -> usize { + self.excluded.capacity() * core::mem::size_of::() + } +} + +/// Conditions compiled at ingress for repeated pool and builder checks. +/// +/// Timing comparisons are reduced to bounds. State predicates are partitioned +/// by type; timing always gates state reads. The submitted count is retained +/// because transaction ordering uses it, even when redundant bounds collapse. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ValidityConditions { + block: ValidityBounds, + flashblock: ValidityBounds, + balance: Vec, + nonce: Vec, + storage: Vec, + /// Original wire batch, used only when forwarding to a builder. + submitted: Vec, +} + +impl ValidityConditions { + /// Compiles submitted predicates without modifying the wire representation. + pub fn new(predicates: Vec) -> Self { + // Count partitions before cloning large predicates so larger batches do not + // repeatedly grow buffers, and small partitions do not retain minimum Vec capacity. + let mut balance_count = 0; + let mut nonce_count = 0; + let mut storage_count = 0; + let mut block_exclusions = 0; + let mut flashblock_exclusions = 0; + for predicate in &predicates { + match predicate { + ValidityPredicate::Balance { .. } => balance_count += 1, + ValidityPredicate::Nonce { .. } => nonce_count += 1, + ValidityPredicate::Storage { .. } => storage_count += 1, + ValidityPredicate::BlockNumber { op: ValidityOperator::NotEqual, .. } => { + block_exclusions += 1; + } + ValidityPredicate::FlashblockIndex { op: ValidityOperator::NotEqual, .. } => { + flashblock_exclusions += 1; + } + _ => {} + } + } + let mut conditions = Self { submitted: predicates, ..Self::default() }; + conditions.balance.reserve_exact(balance_count); + conditions.nonce.reserve_exact(nonce_count); + conditions.storage.reserve_exact(storage_count); + conditions.block.excluded.reserve_exact(block_exclusions); + conditions.flashblock.excluded.reserve_exact(flashblock_exclusions); + for predicate in &conditions.submitted { + match predicate { + ValidityPredicate::BlockNumber { op, value } => { + conditions.block.insert(*op, *value) + } + ValidityPredicate::FlashblockIndex { op, value } => { + conditions.flashblock.insert(*op, *value) + } + ValidityPredicate::Balance { .. } => conditions.balance.push(predicate.clone()), + ValidityPredicate::Nonce { .. } => conditions.nonce.push(predicate.clone()), + ValidityPredicate::Storage { .. } => conditions.storage.push(predicate.clone()), + } + } + conditions + } + + /// Shared empty conditions for transaction types without validity metadata. + pub fn empty() -> &'static Self { + static EMPTY: OnceLock = OnceLock::new(); + EMPTY.get_or_init(Self::default) + } + + /// Original predicates for lossless builder forwarding; never used for evaluation. + pub const fn submitted(&self) -> &[ValidityPredicate] { + self.submitted.as_slice() + } + + /// Block-number bounds. + pub const fn block(&self) -> &ValidityBounds { + &self.block + } + + /// Flashblock-index bounds, whose indices reset each block. + pub const fn flashblock(&self) -> &ValidityBounds { + &self.flashblock + } + + /// Number of submitted predicates, before normalization. + pub const fn len(&self) -> usize { + self.submitted.len() + } + + /// Whether the transaction carries no validity conditions. + pub const fn is_empty(&self) -> bool { + self.submitted.is_empty() + } + + /// State predicates in balance, nonce, then storage order. + pub fn state_predicates(&self) -> impl Iterator { + self.balance.iter().chain(&self.nonce).chain(&self.storage) + } + + /// Normalized predicates in evaluation order, for parking and resting checks. + /// + /// Timing predicates are synthesized; state predicates are borrowed so successful + /// evaluation and membership checks do not clone them. + pub fn iter(&self) -> impl Iterator> + '_ { + self.block + .comparisons() + .map(|(op, value)| Cow::Owned(ValidityPredicate::BlockNumber { op, value })) + .chain( + self.flashblock.comparisons().map(|(op, value)| { + Cow::Owned(ValidityPredicate::FlashblockIndex { op, value }) + }), + ) + .chain(self.state_predicates().map(Cow::Borrowed)) + } + + /// Whether these conditions contain the normalized predicate recorded by a resting iterator. + pub fn contains(&self, predicate: &ValidityPredicate) -> bool { + match predicate { + ValidityPredicate::BlockNumber { op, value } => self.block.contains(*op, *value), + ValidityPredicate::FlashblockIndex { op, value } => { + self.flashblock.contains(*op, *value) + } + ValidityPredicate::Balance { .. } => self.balance.contains(predicate), + ValidityPredicate::Nonce { .. } => self.nonce.contains(predicate), + ValidityPredicate::Storage { .. } => self.storage.contains(predicate), + } + } + + /// Whether a passed timing upper bound proves permanent expiry. + /// + /// A flashblock deadline is terminal only in the last allowed block. State + /// mismatches and contradictory lower bounds do not change expiry policy. + pub fn is_expired(&self, context: &PredicateContext) -> bool { + let block = U256::from(context.block_number); + self.block.below_zero + || self.flashblock.below_zero + || self.block.max.is_some_and(|max| max < block) + || (self.block.max == Some(block) + && self + .flashblock + .max + .is_some_and(|max| max < U256::from(context.flashblock_index))) + } + + /// Heap bytes used by the partitioned conditions. + pub const fn heap_size(&self) -> usize { + self.block.heap_size() + + self.flashblock.heap_size() + + (self.balance.capacity() + + self.nonce.capacity() + + self.storage.capacity() + + self.submitted.capacity()) + * core::mem::size_of::() + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use alloy_primitives::{Address, U256}; + use revm::database::InMemoryDB; + + use super::{ValidityBounds, ValidityConditions}; + use crate::{ + PredicateContext, ValidityOperator, + ValidityOperator::{ + Equal, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, NotEqual, + }, + ValidityPredicate, + }; + + #[test] + fn compiled_comparisons_cover_operators_and_full_width_boundaries() { + let beyond_context = U256::from(u64::MAX) + U256::ONE; + for (op, threshold, positions, expected) in [ + (LessThan, U256::from(2), [1, 2, 3], [true, false, false]), + (LessThanOrEqual, U256::from(2), [1, 2, 3], [true, true, false]), + (Equal, U256::from(2), [1, 2, 3], [false, true, false]), + (NotEqual, U256::from(2), [1, 2, 3], [true, false, true]), + (GreaterThan, U256::from(2), [1, 2, 3], [false, false, true]), + (GreaterThanOrEqual, U256::from(2), [1, 2, 3], [false, true, true]), + (LessThan, U256::ZERO, [0, 1, u64::MAX], [false; 3]), + (LessThanOrEqual, U256::ZERO, [0, 1, u64::MAX], [true, false, false]), + (GreaterThan, U256::MAX, [0, 1, u64::MAX], [false; 3]), + (GreaterThanOrEqual, U256::MAX, [0, 1, u64::MAX], [false; 3]), + (Equal, beyond_context, [0, 1, u64::MAX], [false; 3]), + (GreaterThanOrEqual, beyond_context, [0, 1, u64::MAX], [false; 3]), + (LessThan, beyond_context, [0, 1, u64::MAX], [true; 3]), + (LessThanOrEqual, U256::MAX, [0, 1, u64::MAX], [true; 3]), + (NotEqual, U256::MAX, [0, 1, u64::MAX], [true; 3]), + ] { + let mut bounds = ValidityBounds::default(); + bounds.insert(op, threshold); + for (position, matches) in positions.into_iter().zip(expected) { + assert_eq!( + bounds.first_unsatisfied(U256::from(position)).is_none(), + matches, + "{op:?} {threshold} at {position}" + ); + } + } + let mut bounds = ValidityBounds::default(); + assert_eq!(bounds.first_unsatisfied(U256::ZERO), None); + assert_eq!(bounds.first_unsatisfied(U256::MAX), None); + bounds.insert(Equal, U256::MAX); + assert_eq!(bounds.first_unsatisfied(U256::MAX), None); + } + + #[test] + fn normalized_membership_is_exact_not_logical_implication() { + for flashblock in [false, true] { + let predicate = |op, value| { + if flashblock { + ValidityPredicate::FlashblockIndex { op, value } + } else { + ValidityPredicate::BlockNumber { op, value } + } + }; + let conditions = ValidityConditions::new(vec![ + predicate(GreaterThan, U256::from(9)), + predicate(LessThan, U256::from(21)), + predicate(Equal, U256::from(15)), + predicate(NotEqual, U256::from(15)), + predicate(NotEqual, U256::from(15)), + predicate(LessThan, U256::ZERO), + predicate(GreaterThan, U256::MAX), + ]); + for (op, value) in [ + (GreaterThanOrEqual, U256::from(15)), + (LessThanOrEqual, U256::from(15)), + (NotEqual, U256::from(15)), + (LessThan, U256::ZERO), + (GreaterThan, U256::MAX), + ] { + assert!(conditions.contains(&predicate(op, value))); + } + for (op, value) in [ + (Equal, U256::from(15)), + (GreaterThan, U256::from(9)), + (LessThan, U256::from(21)), + (GreaterThanOrEqual, U256::from(10)), + (LessThanOrEqual, U256::from(20)), + (NotEqual, U256::from(14)), + ] { + assert!(!conditions.contains(&predicate(op, value))); + } + // The other timing partition is absent, even at the same threshold. + assert!(!conditions.contains(&if flashblock { + ValidityPredicate::BlockNumber { op: NotEqual, value: U256::from(15) } + } else { + ValidityPredicate::FlashblockIndex { op: NotEqual, value: U256::from(15) } + })); + } + } + + #[test] + fn first_blocker_preserves_impossible_bound_and_exclusion_order() { + let mut bounds = ValidityBounds::default(); + bounds.insert(NotEqual, U256::from(5)); + bounds.insert(NotEqual, U256::from(5)); + bounds.insert(GreaterThanOrEqual, U256::from(5)); + bounds.insert(LessThanOrEqual, U256::from(3)); + assert_eq!( + bounds.first_unsatisfied(U256::from(4)), + Some((GreaterThanOrEqual, U256::from(5))) + ); + assert_eq!(bounds.first_unsatisfied(U256::from(5)), Some((LessThanOrEqual, U256::from(3)))); + bounds.insert(GreaterThan, U256::MAX); + assert_eq!(bounds.first_unsatisfied(U256::from(4)), Some((GreaterThan, U256::MAX))); + bounds.insert(LessThan, U256::ZERO); + assert_eq!(bounds.first_unsatisfied(U256::from(4)), Some((LessThan, U256::ZERO))); + + let mut exclusions = ValidityBounds::default(); + exclusions.insert(NotEqual, U256::from(5)); + exclusions.insert(NotEqual, U256::from(5)); + exclusions.insert(NotEqual, U256::from(6)); + assert_eq!(exclusions.first_unsatisfied(U256::from(5)), Some((NotEqual, U256::from(5)))); + assert_eq!(exclusions.first_unsatisfied(U256::from(6)), Some((NotEqual, U256::from(6)))); + assert_eq!(exclusions.first_unsatisfied(U256::from(7)), None); + } + + #[test] + fn heap_size_counts_spare_capacity_in_every_backing_vector() { + let mut conditions = ValidityConditions { + balance: Vec::with_capacity(3), + nonce: Vec::with_capacity(5), + storage: Vec::with_capacity(7), + submitted: Vec::with_capacity(11), + ..Default::default() + }; + conditions.block.excluded.reserve_exact(4); + conditions.flashblock.excluded.reserve_exact(6); + conditions.block.insert(ValidityOperator::NotEqual, U256::ONE); + conditions.flashblock.insert(ValidityOperator::NotEqual, U256::ONE); + assert_eq!( + conditions.heap_size(), + 26 * core::mem::size_of::() + 10 * core::mem::size_of::() + ); + + let mut submitted = Vec::with_capacity(13); + submitted.push(ValidityPredicate::BlockNumber { + op: ValidityOperator::GreaterThan, + value: U256::ZERO, + }); + let conditions = ValidityConditions::new(submitted); + assert_eq!(conditions.heap_size(), 13 * core::mem::size_of::()); + assert_eq!(conditions.len(), 1); + } + + #[test] + fn normalized_iteration_borrows_state_and_preserves_membership() { + let block = ValidityPredicate::BlockNumber { + op: ValidityOperator::GreaterThan, + value: U256::from(9), + }; + let normalized_block = ValidityPredicate::BlockNumber { + op: ValidityOperator::GreaterThanOrEqual, + value: U256::from(10), + }; + let flashblock = ValidityPredicate::FlashblockIndex { + op: ValidityOperator::NotEqual, + value: U256::from(2), + }; + let balance = ValidityPredicate::Balance { + address: Address::ZERO, + op: ValidityOperator::Equal, + value: U256::ZERO, + }; + let nonce = ValidityPredicate::Nonce { + address: Address::ZERO, + op: ValidityOperator::Equal, + value: U256::ZERO, + }; + let storage = ValidityPredicate::Storage { + address: Address::ZERO, + slot: U256::ZERO, + mask: U256::MAX, + op: ValidityOperator::Equal, + value: U256::ZERO, + }; + let raw = vec![ + storage.clone(), + block.clone(), + nonce.clone(), + flashblock.clone(), + balance.clone(), + ]; + let conditions = ValidityConditions::new(raw.clone()); + let normalized = conditions.iter().collect::>(); + assert_eq!( + normalized, + vec![ + Cow::Owned(normalized_block.clone()), + Cow::Owned(flashblock.clone()), + Cow::Borrowed(&balance), + Cow::Borrowed(&nonce), + Cow::Borrowed(&storage), + ] + ); + assert!(normalized[2..].iter().all(|predicate| matches!(predicate, Cow::Borrowed(_)))); + for predicate in [&normalized_block, &flashblock, &balance, &nonce, &storage] { + assert!(conditions.contains(predicate)); + } + assert!(!conditions.contains(&block)); + assert!(!conditions.contains(&ValidityPredicate::Nonce { + address: Address::ZERO, + op: ValidityOperator::Equal, + value: U256::from(1), + })); + assert_eq!(conditions.submitted(), raw); + assert_eq!(conditions.len(), raw.len()); + } + + #[test] + fn normalized_timing_conjunctions_preserve_matching_and_expiry() { + let operators = [ + ValidityOperator::LessThan, + ValidityOperator::LessThanOrEqual, + ValidityOperator::Equal, + ValidityOperator::NotEqual, + ValidityOperator::GreaterThan, + ValidityOperator::GreaterThanOrEqual, + ]; + let values = [ + U256::ZERO, + U256::from(1), + U256::from(2), + U256::from(u64::MAX), + U256::from(u64::MAX) + U256::from(1), + U256::MAX, + ]; + let mut db = InMemoryDB::default(); + for flashblock in [false, true] { + for first in operators { + for second in operators { + for left in values { + for right in values { + let predicate = |op, value| { + if flashblock { + ValidityPredicate::FlashblockIndex { op, value } + } else { + ValidityPredicate::BlockNumber { op, value } + } + }; + let raw = [predicate(first, left), predicate(second, right)]; + let conditions = ValidityConditions::new(raw.to_vec()); + assert_eq!( + conditions.block().expiry_bound(), + ValidityPredicate::block_expiry_bound(&raw) + ); + assert_eq!( + conditions.flashblock().expiry_bound(), + ValidityPredicate::flashblock_expiry_bound(&raw) + ); + for position in [0, 1, 2, 3, u64::MAX] { + let context = PredicateContext { + block_number: position, + flashblock_index: position, + }; + let expected = raw + .iter() + .all(|predicate| predicate.matches(&mut db, &context).unwrap()); + let actual = conditions + .iter() + .all(|predicate| predicate.matches(&mut db, &context).unwrap()); + assert_eq!(actual, expected, "{raw:?} at {context:?}"); + let direct = conditions + .block() + .first_unsatisfied(U256::from(position)) + .is_none() + && conditions + .flashblock() + .first_unsatisfied(U256::from(position)) + .is_none(); + assert_eq!(direct, expected, "compiled {raw:?} at {context:?}"); + assert_eq!( + conditions.is_expired(&context), + ValidityPredicate::is_batch_expired(&raw, &context) + ); + } + } + } + } + } + } + } + + #[test] + fn tightest_bounds_keep_exclusions_and_submission_count() { + let raw = vec![ + ValidityPredicate::BlockNumber { + op: ValidityOperator::GreaterThan, + value: U256::from(9), + }, + ValidityPredicate::BlockNumber { + op: ValidityOperator::GreaterThanOrEqual, + value: U256::from(8), + }, + ValidityPredicate::BlockNumber { + op: ValidityOperator::LessThan, + value: U256::from(21), + }, + ValidityPredicate::BlockNumber { + op: ValidityOperator::LessThanOrEqual, + value: U256::from(30), + }, + ValidityPredicate::BlockNumber { + op: ValidityOperator::NotEqual, + value: U256::from(15), + }, + ]; + let conditions = ValidityConditions::new(raw.clone()); + assert_eq!(conditions.submitted(), raw); + assert_eq!(conditions.block().min(), Some(U256::from(10))); + assert_eq!(conditions.block().max(), Some(U256::from(20))); + assert_eq!(conditions.len(), raw.len()); + assert!(!conditions.is_empty()); + let mut db = InMemoryDB::default(); + for (block_number, expected) in + [(9, false), (10, true), (15, false), (20, true), (21, false)] + { + let context = PredicateContext { block_number, flashblock_index: 1 }; + assert_eq!( + conditions.iter().all(|predicate| predicate.matches(&mut db, &context).unwrap()), + expected + ); + } + } + + #[test] + fn flashblock_expiry_resets_until_last_allowed_block() { + let conditions = ValidityConditions::new(vec![ + ValidityPredicate::BlockNumber { + op: ValidityOperator::LessThanOrEqual, + value: U256::from(102), + }, + ValidityPredicate::FlashblockIndex { + op: ValidityOperator::LessThan, + value: U256::from(3), + }, + ]); + for (block_number, flashblock_index, expired) in + [(100, 3, false), (101, 1, false), (102, 2, false), (102, 3, true), (103, 1, true)] + { + let context = PredicateContext { block_number, flashblock_index }; + assert_eq!(conditions.is_expired(&context), expired); + } + } + + #[test] + fn state_predicates_remain_enforced_and_do_not_establish_expiry() { + let address = Address::ZERO; + let balance = + ValidityPredicate::Balance { address, op: ValidityOperator::Equal, value: U256::ZERO }; + let nonce = + ValidityPredicate::Nonce { address, op: ValidityOperator::Equal, value: U256::ZERO }; + let storage = ValidityPredicate::Storage { + address, + slot: U256::ZERO, + mask: U256::MAX, + op: ValidityOperator::Equal, + value: U256::from(1), + }; + let conditions = + ValidityConditions::new(vec![storage.clone(), nonce.clone(), balance.clone()]); + let context = PredicateContext { block_number: 100, flashblock_index: 1 }; + let mut db = InMemoryDB::default(); + assert!(conditions.contains(&balance)); + assert!(conditions.contains(&nonce)); + assert!(conditions.contains(&storage)); + assert!(!conditions.iter().all(|predicate| predicate.matches(&mut db, &context).unwrap())); + assert!(!conditions.is_expired(&context)); + assert_eq!(conditions.block().expiry_bound(), None); + } + + #[test] + fn explicit_unbounded_flashblock_comparisons_are_still_present() { + let mut bounds = ValidityBounds::default(); + bounds.insert(ValidityOperator::GreaterThanOrEqual, U256::ZERO); + assert!(bounds.is_present()); + assert_eq!(bounds.expiry_bound(), None); + } +} diff --git a/crates/execution/txpool/src/lib.rs b/crates/execution/txpool/src/lib.rs index be4cf5f37b..fbdb36fa16 100644 --- a/crates/execution/txpool/src/lib.rs +++ b/crates/execution/txpool/src/lib.rs @@ -27,6 +27,9 @@ pub use validator::{BaseL1BlockInfo, BaseTransactionValidator, BaseTxPoolError, mod best; +mod conditions; +pub use conditions::{ValidityBounds, ValidityConditions}; + mod validity_signature_mode; pub use validity_signature_mode::ValiditySignatureMode; diff --git a/crates/execution/txpool/src/ordering.rs b/crates/execution/txpool/src/ordering.rs index b1f5cc4181..4fecaac48c 100644 --- a/crates/execution/txpool/src/ordering.rs +++ b/crates/execution/txpool/src/ordering.rs @@ -184,7 +184,7 @@ where let Some(effective_tip) = transaction.effective_tip_per_gas(base_fee) else { return Priority::None; }; - let predicates = transaction.validity_predicates().len(); + let predicates = transaction.validity_conditions().len(); if let Some(signed) = transaction.as_eip8130() && let Some(tip) = CoinbaseTip::decode(signed.tx()) { @@ -608,6 +608,25 @@ mod tests { assert!(ordering.priority(&one_predicate, 0) > ordering.priority(&two_predicates, 0)); } + #[test] + fn priority_fee_preserves_submitted_predicate_tiebreak() { + let ordering = UnifiedTipOrdering::::default(); + let predicates = vec![block_number_predicate(), block_number_predicate()]; + let transaction = + eip8130_pooled(10, 2, 21_000, None).with_validity_predicates(predicates.clone()); + let same_fee_and_count = + eip1559_pooled(1, 10, 2, 21_000).with_validity_predicates(predicates); + let fewer_predicates = eip1559_pooled(2, 10, 2, 21_000) + .with_validity_predicates(vec![block_number_predicate()]); + + assert_eq!( + ordering.priority(&transaction, 0), + ordering.priority(&same_fee_and_count, 0), + "compilation must preserve duplicate predicates when ranking priority-fee bids" + ); + assert!(ordering.priority(&fewer_predicates, 0) > ordering.priority(&transaction, 0)); + } + #[test] fn eip8130_without_static_tip_uses_priority_fee() { let ordering = UnifiedTipOrdering::::default(); diff --git a/crates/execution/txpool/src/pool.rs b/crates/execution/txpool/src/pool.rs index a59e04b964..be2bc72a61 100644 --- a/crates/execution/txpool/src/pool.rs +++ b/crates/execution/txpool/src/pool.rs @@ -29,7 +29,7 @@ use crate::{ Admission, BasePooledTx, BaseTransactionValidator, FlashblockExpiry, GuardLimits, GuardMetrics, InvalidationCause, InvalidationKey, LimitRejection, MempoolGuard, ParkableBestTransactions, ParkableTransactionPool, ParkedBestTransactions, PredicateContext, StateDiffInvalidation, - ValidityPoolMetrics, ValidityPredicate, + ValidityPoolMetrics, best::MergeBestTransactions, two_d_nonce_pool::{InsertOutcome, TwoDNoncePool}, }; @@ -350,9 +350,7 @@ where /// predicates, or `None` when they impose no finite block bound. fn validity_block_expiry_bound(validated: &TransactionValidationOutcome) -> Option { let transaction = validated.as_valid_transaction()?; - crate::ValidityPredicate::block_expiry_bound( - transaction.transaction().validity_predicates(), - ) + transaction.transaction().validity_conditions().block().expiry_bound() } /// Optional flashblock upper bound, paired with the block bound for pool eviction. @@ -360,7 +358,7 @@ where validated: &TransactionValidationOutcome, ) -> Option { let transaction = validated.as_valid_transaction()?; - ValidityPredicate::flashblock_expiry_bound(transaction.transaction().validity_predicates()) + transaction.transaction().validity_conditions().flashblock().expiry_bound() } /// Rejects a late arrival whose deadline is behind the published position. @@ -376,10 +374,7 @@ where block_number: position.block_number, flashblock_index: position.flashblock_index.saturating_add(1), }; - if ValidityPredicate::is_batch_expired( - transaction.transaction().validity_predicates(), - &next, - ) { + if transaction.transaction().validity_conditions().is_expired(&next) { return Err(reth_transaction_pool::error::PoolError::other( hash, "validity deadline expired at published flashblock", @@ -469,7 +464,7 @@ where fn has_validity_predicates(validated: &TransactionValidationOutcome) -> bool { validated .as_valid_transaction() - .is_some_and(|transaction| !transaction.transaction().validity_predicates().is_empty()) + .is_some_and(|transaction| !transaction.transaction().validity_conditions().is_empty()) } fn reconcile_guard(&self) { @@ -735,7 +730,7 @@ where .limit_class() .map(|class| class.classification_generation); let admission = Self::admission_for(&validated.transaction); - let is_validity = !validated.transaction.validity_predicates().is_empty(); + let is_validity = !validated.transaction.validity_conditions().is_empty(); let outcome = nonce_pool.insert_validated(validated, state_nonce)?; // nonce_pool serializes sidecar replacement. Never acquire it while holding guard. let mut guard = self.guard.write(); diff --git a/crates/execution/txpool/src/transaction.rs b/crates/execution/txpool/src/transaction.rs index 3bda93bbf8..3c9a0520b7 100644 --- a/crates/execution/txpool/src/transaction.rs +++ b/crates/execution/txpool/src/transaction.rs @@ -58,9 +58,8 @@ pub struct BasePooledTransaction< encoded_2718: OnceLock, /// Timestamp (millis since Unix epoch) when this transaction was received. received_at: u128, - /// State predicates that must hold before this transaction is eligible for - /// inclusion. - validity_predicates: Vec, + /// Compiled conditions, shared across payload candidates; absent for ordinary transactions. + validity_conditions: Option>, /// Sender authorization retained for builder forwarding. validity_signature: Option, /// The set of on-chain state surfaces whose change invalidates this @@ -105,7 +104,7 @@ impl BasePooledTransaction { _pd: core::marker::PhantomData, encoded_2718: Default::default(), received_at, - validity_predicates: Vec::new(), + validity_conditions: None, validity_signature: None, watch_set: OnceLock::new(), limit_class: OnceLock::new(), @@ -128,24 +127,30 @@ impl BasePooledTransaction { /// Sets the validity predicates required for this transaction's inclusion. /// - /// Predicates are stored in canonical evaluation order (timing before - /// state) via [`crate::ValidityPredicate::sort_batch`], so every ingress - /// path yields transactions whose cheap timing predicates gate state reads. + /// Conditions are compiled once into timing bounds and separate state groups. + /// The submitted batch is retained only for lossless builder forwarding. #[must_use] pub fn with_validity_predicates( mut self, - mut validity_predicates: Vec, + validity_predicates: Vec, ) -> Self { - crate::ValidityPredicate::sort_batch(&mut validity_predicates); - self.validity_predicates = validity_predicates; + self.validity_conditions = (!validity_predicates.is_empty()) + .then(|| Arc::new(crate::ValidityConditions::new(validity_predicates))); self.validity_signature = None; self } - /// Returns the validity predicates required for this transaction's inclusion. + /// Returns the submitted predicates for lossless builder forwarding. + /// Pool and builder checks must use [`Self::validity_conditions`] instead. #[must_use] pub fn validity_predicates(&self) -> &[crate::ValidityPredicate] { - &self.validity_predicates + self.validity_conditions().submitted() + } + + /// Returns compiled validity conditions for pool and builder checks. + #[must_use] + pub fn validity_conditions(&self) -> &crate::ValidityConditions { + self.validity_conditions.as_deref().unwrap_or_else(|| crate::ValidityConditions::empty()) } /// Attaches a validity sidecar without verifying its signature. @@ -200,7 +205,7 @@ where replacement: &Self, price_bumps: &PriceBumpConfig, ) -> bool { - if !self.validity_predicates().is_empty() && !replacement.validity_predicates().is_empty() { + if !self.validity_conditions().is_empty() && !replacement.validity_conditions().is_empty() { return replacement.max_fee_per_gas() <= self.max_fee_per_gas(); } if self.ty() == EIP8130_TX_TYPE_ID || replacement.ty() == EIP8130_TX_TYPE_ID { @@ -279,13 +284,15 @@ impl InMemorySize for BasePooledTransaction() + conditions.heap_size() + }); let metering_size = self.metering.as_ref().map_or(0, |metering| { core::mem::size_of::() + metering.heap_size() }); self.inner.size() + core::mem::size_of::() - + core::mem::size_of::>() + + core::mem::size_of::>>() + core::mem::size_of::>() + watch_keys_size + core::mem::size_of::>() @@ -410,14 +417,22 @@ pub trait BasePooledTx: PoolTransaction + DataAvailabilitySized { /// Returns the EIP-2718 encoded bytes of the transaction. fn encoded_2718(&self) -> Cow<'_, Bytes>; - /// Returns state predicates required for this transaction's inclusion. + /// Returns submitted predicates for builder forwarding, not repeated evaluation. /// - /// Defaults to an empty slice for transaction types that do not carry - /// validity predicates. + /// Derived from [`Self::validity_conditions`] so forwarding and enforcement + /// use the same ingested metadata. fn validity_predicates(&self) -> &[crate::ValidityPredicate] { - &[] + self.validity_conditions().submitted() } + /// Returns the compiled conditions used for pool and builder enforcement. + /// + /// Implementations must supply their ingested metadata, or explicitly return + /// [`crate::ValidityConditions::empty`] when they never carry validity predicates. + /// This required source prevents legacy implementations from silently forwarding + /// predicates that are not enforced. + fn validity_conditions(&self) -> &crate::ValidityConditions; + /// Returns the signed EIP-8130 payload when this transaction carries one. /// /// Required for the mempool validator's structural admission checks; the @@ -495,8 +510,8 @@ where Cow::Borrowed(self.encoded_2718()) } - fn validity_predicates(&self) -> &[crate::ValidityPredicate] { - &self.validity_predicates + fn validity_conditions(&self) -> &crate::ValidityConditions { + self.validity_conditions.as_deref().unwrap_or_else(|| crate::ValidityConditions::empty()) } fn as_eip8130(&self) -> Option<&Eip8130Signed> { @@ -909,6 +924,43 @@ mod tests { ); } + #[test] + fn validity_clones_share_lossless_batch_count_and_allocated_size() { + let transaction = eip8130_pooled(U256::ZERO); + let bare_size = transaction.size(); + let balance = ValidityPredicate::Balance { + address: Address::ZERO, + op: ValidityOperator::Equal, + value: U256::ZERO, + }; + let block = ValidityPredicate::BlockNumber { + op: ValidityOperator::GreaterThan, + value: U256::from(9), + }; + let mut submitted = Vec::with_capacity(17); + submitted.extend([balance.clone(), block.clone(), block, balance]); + let expected = submitted.clone(); + let transaction = transaction.with_validity_predicates(submitted); + let cloned = transaction.clone(); + assert!(core::ptr::eq(transaction.validity_conditions(), cloned.validity_conditions())); + assert_eq!(cloned.validity_predicates(), expected); + assert_eq!(BasePooledTx::validity_predicates(&cloned), expected); + assert_eq!(cloned.validity_conditions().len(), 4); + assert_eq!(cloned.validity_conditions().block().min(), Some(U256::from(10))); + assert_eq!(transaction.size(), cloned.size()); + assert_eq!( + transaction.size(), + bare_size + + core::mem::size_of::() + + transaction.validity_conditions().heap_size() + ); + assert!( + transaction.validity_conditions().heap_size() + >= 19 * core::mem::size_of::(), + "submitted spare capacity plus the two state predicates must be counted" + ); + } + #[test] fn retains_validity_predicates() { let predicate = ValidityPredicate::Balance { @@ -924,5 +976,8 @@ mod tests { BasePooledTx::validity_predicates(&transaction), core::slice::from_ref(&predicate) ); + assert!(transaction.validity_conditions().contains(&predicate)); + let cloned = transaction.clone(); + assert!(core::ptr::eq(transaction.validity_conditions(), cloned.validity_conditions())); } } diff --git a/crates/execution/txpool/src/validity.rs b/crates/execution/txpool/src/validity.rs index e55bace252..3a07c8a246 100644 --- a/crates/execution/txpool/src/validity.rs +++ b/crates/execution/txpool/src/validity.rs @@ -548,26 +548,6 @@ impl ValidityPredicate { .min() .and_then(|bound| u64::try_from(bound).ok()) } - - /// Stable-sorts a predicate batch into canonical evaluation order: timing - /// predicates ([`Self::BlockNumber`], [`Self::FlashblockIndex`]) before state - /// predicates ([`Self::Balance`], [`Self::Nonce`], [`Self::Storage`]). The batch is a pure - /// conjunction, so reordering only affects cost: a timing mismatch - /// short-circuits before any state read and parks under a context key that - /// per-transaction state changes never wake. - pub fn sort_batch(predicates: &mut [Self]) { - predicates.sort_by_key(Self::evaluation_rank); - } - - /// Returns the canonical evaluation rank: block number, then flashblock - /// index, then state predicates. - const fn evaluation_rank(&self) -> u8 { - match self { - Self::BlockNumber { .. } => 0, - Self::FlashblockIndex { .. } => 1, - Self::Balance { .. } | Self::Nonce { .. } | Self::Storage { .. } => 2, - } - } } /// Deserializes a sequence of [`ValidityPredicate`] while rejecting the batch as @@ -1036,45 +1016,7 @@ mod tests { } #[test] - fn sort_batch_orders_timing_predicates_before_state_predicates() { - let balance = |value: u64| ValidityPredicate::Balance { - address: Address::ZERO, - op: ValidityOperator::Equal, - value: U256::from(value), - }; - let flashblock_index = ValidityPredicate::FlashblockIndex { - op: ValidityOperator::LessThan, - value: U256::from(5), - }; - let block_number = ValidityPredicate::BlockNumber { - op: ValidityOperator::GreaterThanOrEqual, - value: U256::from(100), - }; - let storage = ValidityPredicate::Storage { - address: Address::ZERO, - slot: U256::ZERO, - mask: U256::MAX, - op: ValidityOperator::Equal, - value: U256::ZERO, - }; - // Scrambled submission order. - let mut predicates = vec![ - balance(1), - storage.clone(), - flashblock_index.clone(), - balance(2), - block_number.clone(), - ]; - - ValidityPredicate::sort_batch(&mut predicates); - - // Timing predicates (block number, then flashblock index) lead; state - // predicates follow in stable submission order. - assert_eq!(predicates, [block_number, flashblock_index, balance(1), storage, balance(2)]); - } - - #[test] - fn apply_stores_predicates_in_canonical_evaluation_order() { + fn apply_compiles_timing_before_state_and_preserves_wire_order() { let signed: BaseTransactionSigned = TxDeposit { source_hash: Default::default(), from: Address::ZERO, @@ -1108,7 +1050,18 @@ mod tests { let transaction = extension.apply(transaction).unwrap(); - assert_eq!(transaction.validity_predicates(), [timing_predicate, state_predicate]); + assert_eq!( + transaction.validity_predicates(), + [state_predicate.clone(), timing_predicate.clone()] + ); + assert_eq!( + transaction + .validity_conditions() + .iter() + .map(|predicate| predicate.into_owned()) + .collect::>(), + [timing_predicate, state_predicate] + ); } #[test] From 444cff3833d09718d002f7b2660b88a5a1a014e8 Mon Sep 17 00:00:00 2001 From: xenoliss Date: Mon, 19 Oct 2026 20:56:09 +0200 Subject: [PATCH 06/18] fix(batcher): backport batcher audit fixes to v1.5.1 (#6099) * fix(tx-manager): always raise a zero fee in a replacement (#6085) (cherry picked from commit f3685f3f26632fc0c5de5102267bd10939a2cfa7) * fix(batcher): let the shutdown signal handler own SIGINT (#6087) * fix(batcher): let the shutdown signal handler own SIGINT * fix(batcher): stop setup on the shutdown signal (cherry picked from commit 7ae66330b9cb92d46dd6d9b8703f60af72d81f5c) * fix(batcher): reject a batcher hash that derivation rejects (#6086) (cherry picked from commit 41a702d4e3fe858b95bd481cd673436effa4ce52) --- Cargo.lock | 3 +- bin/base/src/commands/command.rs | 3 +- crates/batcher/cli/Cargo.toml | 3 +- crates/batcher/cli/src/cli.rs | 21 +++++++++----- crates/batcher/service/src/service.rs | 11 +++---- crates/batcher/service/src/system_config.rs | 16 ++++------- crates/utilities/tx-manager/src/fees.rs | 32 ++++++--------------- 7 files changed, 40 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d5029ec6a..c0b4bf4b55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3058,7 +3058,6 @@ dependencies = [ "base-batcher-core", "base-batcher-encoder", "base-batcher-service", - "base-cli-utils", "base-runtime", "base-tx-manager", "clap", @@ -3066,6 +3065,8 @@ dependencies = [ "humantime", "metrics", "metrics-exporter-prometheus", + "tokio", + "tokio-util", "tracing", "url", ] diff --git a/bin/base/src/commands/command.rs b/bin/base/src/commands/command.rs index ea0d53026a..a509395749 100644 --- a/bin/base/src/commands/command.rs +++ b/bin/base/src/commands/command.rs @@ -57,7 +57,8 @@ impl BaseCommand { match self { Self::Batcher(batcher) => { chain_resolver.reject_for_reth_command("base batcher")?; - RuntimeManager::new().run_until_ctrl_c((*batcher).exec(metrics_enabled)) + RuntimeManager::new() + .run_until_shutdown(|cancel| (*batcher).exec(metrics_enabled, cancel)) } Self::Bootnode(bootnode) => (*bootnode).run(chain_resolver.resolve()?, metrics_enabled), Self::Rpc(rpc) => (*rpc).run(chain_resolver.resolve()?), diff --git a/crates/batcher/cli/Cargo.toml b/crates/batcher/cli/Cargo.toml index e3d9f63bf3..367317d145 100644 --- a/crates/batcher/cli/Cargo.toml +++ b/crates/batcher/cli/Cargo.toml @@ -15,10 +15,11 @@ workspace = true url.workspace = true eyre.workspace = true humantime.workspace = true -base-cli-utils.workspace = true +tokio-util.workspace = true base-tx-manager.workspace = true alloy-signer-local.workspace = true metrics-exporter-prometheus.workspace = true +tokio = { workspace = true, features = ["macros"] } tracing = { workspace = true, features = ["std"] } base-runtime = { workspace = true, features = ["tokio"] } clap = { workspace = true, features = ["derive", "env"] } diff --git a/crates/batcher/cli/src/cli.rs b/crates/batcher/cli/src/cli.rs index 84be2d8eb7..d09baaeddd 100644 --- a/crates/batcher/cli/src/cli.rs +++ b/crates/batcher/cli/src/cli.rs @@ -8,10 +8,10 @@ use std::{ use alloy_primitives::Address; use base_batcher_core::{ThrottleConfig, ThrottleStrategy}; use base_batcher_service::{BatcherConfig, BatcherService, ShadowConfig}; -use base_cli_utils::RuntimeManager; use base_runtime::TokioRuntime; use base_tx_manager::SignerConfig; use clap::Parser; +use tokio_util::sync::CancellationToken; use url::Url; base_tx_manager::define_signer_cli!("BASE_BATCHER"); @@ -401,14 +401,21 @@ impl BatcherArgs { }) } - /// Execute the batcher. - pub async fn exec(self, metrics_enabled: bool) -> eyre::Result<()> { + /// Execute the batcher until `cancellation` is cancelled. + pub async fn exec( + self, + metrics_enabled: bool, + cancellation: CancellationToken, + ) -> eyre::Result<()> { let config = self.into_config(metrics_enabled)?; - let rt = TokioRuntime::new(); - let _signal_handle = RuntimeManager::install_signal_handler(rt.token().clone()); + let rt = TokioRuntime::with_token(cancellation.clone()); - let service = BatcherService::new(config); - service.setup(rt).await?.run().await + let setup = BatcherService::new(config).setup(rt); + let ready = tokio::select! { + ready = setup => ready?, + () = cancellation.cancelled() => return Ok(()), + }; + ready.run().await } } diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index c2237b1405..8bf004204a 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -431,13 +431,13 @@ impl BatcherService { // fees. The shadow batcher posts with its own key on purpose. if self.config.shadow.is_none() { let system_config = rollup_config.l1_system_config_address; - let authorized = Self::rpc_retry("system-config-batcher", retry, rpc_timeout, || { + let batcher_hash = Self::rpc_retry("system-config-batcher", retry, rpc_timeout, || { SystemConfigBatcher::fetch(&l1_provider, system_config) }) .await?; - if authorized != signer_address { + if batcher_hash != signer_address.into_word() { eyre::bail!( - "signer {signer_address} is not the batcher {authorized} authorized by the L1 \ + "signer {signer_address} is not the batcher {batcher_hash} authorized by the L1 \ SystemConfig at {system_config}" ); } @@ -865,8 +865,9 @@ mod tests { assert_eq!( error.to_string(), format!( - "signer {signer} is not the batcher {authorized} authorized by the L1 \ - SystemConfig at {SYSTEM_CONFIG}" + "signer {signer} is not the batcher {batcher_hash} authorized by the L1 \ + SystemConfig at {SYSTEM_CONFIG}", + batcher_hash = authorized.into_word(), ) ); } diff --git a/crates/batcher/service/src/system_config.rs b/crates/batcher/service/src/system_config.rs index 5b28e8ef89..e910b8ada7 100644 --- a/crates/batcher/service/src/system_config.rs +++ b/crates/batcher/service/src/system_config.rs @@ -1,6 +1,6 @@ //! Startup read of the batcher address authorized by the L1 `SystemConfig` contract. -use alloy_primitives::Address; +use alloy_primitives::{Address, B256}; use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types_eth::TransactionRequest; use alloy_sol_types::{SolCall, sol}; @@ -13,17 +13,14 @@ sol! { } } -/// Reads the batcher address that derivation accepts batches from. +/// Reads the batcher hash that derivation accepts batches from. #[derive(Debug)] pub struct SystemConfigBatcher; impl SystemConfigBatcher { - /// Returns the batcher address stored in the L1 `SystemConfig` contract at - /// `system_config`, as of the latest L1 block. - pub async fn fetch( - l1_provider: &RootProvider, - system_config: Address, - ) -> eyre::Result

{ + /// Returns the `batcherHash` stored in the L1 `SystemConfig` contract at `system_config`, + /// as of the latest L1 block. + pub async fn fetch(l1_provider: &RootProvider, system_config: Address) -> eyre::Result { let call = TransactionRequest::default() .to(system_config) .input(ISystemConfig::batcherHashCall {}.abi_encode().into()); @@ -32,7 +29,6 @@ impl SystemConfigBatcher { .latest() .await .map_err(|e| eyre::eyre!("SystemConfig.batcherHash call failed: {e}"))?; - let batcher_hash = ISystemConfig::batcherHashCall::abi_decode_returns(&output)?; - Ok(Address::from_word(batcher_hash)) + Ok(ISystemConfig::batcherHashCall::abi_decode_returns(&output)?) } } diff --git a/crates/utilities/tx-manager/src/fees.rs b/crates/utilities/tx-manager/src/fees.rs index 0bc4a63f5e..948a2d3b58 100644 --- a/crates/utilities/tx-manager/src/fees.rs +++ b/crates/utilities/tx-manager/src/fees.rs @@ -54,33 +54,17 @@ impl FeeCalculator { /// Returns the minimum replacement value that satisfies geth's /// tx-replacement rules. /// - /// * **Regular transactions** (`is_blob = false`): 10 % bump → - /// `x + x / 10`, but always at least `x + 1` when `x > 0`. - /// * **Blob transactions** (`is_blob = true`): 100 % bump → - /// `x + x` (i.e. `2 × x`), but always at least `x + 1` when `x > 0`. - /// * When `x == 0`, returns `0` (there is no fee to bump). + /// * **Regular transactions** (`is_blob = false`): 10 % bump → `x + x / 10`. + /// * **Blob transactions** (`is_blob = true`): 100 % bump → `x + x`. /// - /// The "at least +1" rule prevents stuck bump loops where - /// `x / 10 == 0` for small values. + /// The result is always at least `x + 1`, since geth requires strictly higher fees. /// /// Uses saturating arithmetic — the result is capped at [`u128::MAX`] /// rather than panicking on overflow. #[must_use] pub const fn calc_threshold_value(x: u128, is_blob: bool) -> u128 { - if x == 0 { - return 0; - } - - let bump = if is_blob { - // 100 % bump: x + x = 2x - x - } else { - // 10 % bump: x / 10, but at least 1 - let v = x / 10; - if v > 0 { v } else { 1 } - }; - - x.saturating_add(bump) + let bump = if is_blob { x } else { x / 10 }; + x.saturating_add(if bump > 0 { bump } else { 1 }) } /// Selects final `(tip, fee_cap)` values that satisfy geth's replacement @@ -329,7 +313,7 @@ mod tests { #[rstest] // Regular (non-blob) cases - #[case::zero_regular(0, false, 0)] + #[case::zero_regular(0, false, 1)] #[case::one_regular(1, false, 2)] #[case::nine_regular(9, false, 10)] #[case::ten_regular(10, false, 11)] @@ -339,7 +323,7 @@ mod tests { #[case::small_value_ensures_plus_one(5, false, 6)] #[case::saturates_regular(u128::MAX, false, u128::MAX)] // Blob cases - #[case::zero_blob(0, true, 0)] + #[case::zero_blob(0, true, 1)] #[case::one_blob(1, true, 2)] #[case::ten_blob(10, true, 20)] #[case::hundred_blob(100, true, 200)] @@ -455,7 +439,7 @@ mod tests { } #[test] - fn threshold_forward_progress(x in 1..u128::MAX, is_blob: bool) { + fn threshold_forward_progress(x in 0..u128::MAX, is_blob: bool) { let result = FeeCalculator::calc_threshold_value(x, is_blob); prop_assert!(result > x, "threshold {result} did not advance past {x}"); } From 4f29bcf6a4cecb94ad84acb1a7eabafc8fb26076 Mon Sep 17 00:00:00 2001 From: refcell Date: Tue, 20 Oct 2026 11:59:00 -0400 Subject: [PATCH 07/18] chore(common): schedule Mainnet Denim upgrade (#6125) Activate Denim on Base Mainnet at 1793901600 (Thu Nov 5 2026 18:00:00 UTC), the same time of day as the Mainnet Cobalt activation. --- crates/common/chains/src/chain.rs | 11 +++++--- crates/common/chains/src/config.rs | 2 +- crates/common/chains/src/upgrade.rs | 5 ++++ crates/common/genesis/src/chain/upgrade.rs | 15 +++++------ crates/execution/chainspec/src/spec.rs | 29 ++++++++++++---------- 5 files changed, 36 insertions(+), 26 deletions(-) diff --git a/crates/common/chains/src/chain.rs b/crates/common/chains/src/chain.rs index 91d1dd3e91..687081a5b0 100644 --- a/crates/common/chains/src/chain.rs +++ b/crates/common/chains/src/chain.rs @@ -179,7 +179,10 @@ mod tests { base_mainnet_forks[Cobalt], ForkCondition::Timestamp(ChainConfig::mainnet().cobalt_timestamp.unwrap()) ); - assert_eq!(base_mainnet_forks[Denim], ForkCondition::Never); + assert_eq!( + base_mainnet_forks[Denim], + ForkCondition::Timestamp(ChainConfig::mainnet().denim_timestamp.unwrap()) + ); assert_eq!(base_mainnet_forks[Zenith], ForkCondition::Never); } @@ -348,10 +351,12 @@ mod tests { #[test] fn is_denim_active_at_timestamp() { - // Denim is unscheduled on Base Mainnet and devnet. + // Denim is unscheduled on devnet. let base_mainnet_forks = ChainUpgrades::mainnet(); assert!(!base_mainnet_forks.is_denim_active_at_timestamp(0)); - assert!(!base_mainnet_forks.is_denim_active_at_timestamp(u64::MAX)); + assert!(!base_mainnet_forks.is_denim_active_at_timestamp(1_793_901_599)); + assert!(base_mainnet_forks.is_denim_active_at_timestamp(1_793_901_600)); + assert!(base_mainnet_forks.is_denim_active_at_timestamp(u64::MAX)); let base_sepolia_forks = ChainUpgrades::sepolia(); assert!(!base_sepolia_forks.is_denim_active_at_timestamp(0)); diff --git a/crates/common/chains/src/config.rs b/crates/common/chains/src/config.rs index 4d540f59fe..fc17e1d231 100644 --- a/crates/common/chains/src/config.rs +++ b/crates/common/chains/src/config.rs @@ -475,7 +475,7 @@ const MAINNET: ChainConfig = ChainConfig { azul_timestamp: Some(1_779_991_200), beryl_timestamp: Some(1_782_410_400), cobalt_timestamp: Some(1_790_791_200), - denim_timestamp: None, + denim_timestamp: Some(1_793_901_600), everest_timestamp: None, genesis_l1_hash: b256!("5c13d307623a926cd31415036c8b7fa14572f9dac64528e857a470511fc30771"), diff --git a/crates/common/chains/src/upgrade.rs b/crates/common/chains/src/upgrade.rs index c7b59683f0..5fc47215df 100644 --- a/crates/common/chains/src/upgrade.rs +++ b/crates/common/chains/src/upgrade.rs @@ -347,6 +347,11 @@ mod tests { ChainConfig::mainnet().cobalt_timestamp.unwrap(), BaseUpgrade::Cobalt, ), + ( + Chain::base_mainnet(), + ChainConfig::mainnet().denim_timestamp.unwrap(), + BaseUpgrade::Denim, + ), ( Chain::base_sepolia(), ChainConfig::sepolia().azul_timestamp.unwrap(), diff --git a/crates/common/genesis/src/chain/upgrade.rs b/crates/common/genesis/src/chain/upgrade.rs index 4a76aeda7e..a07329435e 100644 --- a/crates/common/genesis/src/chain/upgrade.rs +++ b/crates/common/genesis/src/chain/upgrade.rs @@ -73,9 +73,9 @@ hardfork!( /// enter the execution fork ladder. /// /// [`Denim`](BaseUpgrade::Denim) is the fourth Base-specific network upgrade. It is scheduled - /// on Base Sepolia and unscheduled on Base Mainnet. It is a first-class upgrade: - /// contract-backed and part of the execution fork ladder, so live chains can activate it once - /// an activation time is configured. + /// on Base Sepolia and Base Mainnet. It is a first-class upgrade: contract-backed and part of + /// the execution fork ladder, so live chains can activate it once an activation time is + /// configured. /// /// [`Everest`](BaseUpgrade::Everest) is the fifth Base-specific network upgrade. It is /// unscheduled for now, contract-backed, and part of the execution fork ladder. @@ -670,7 +670,7 @@ impl UpgradeConfig { azul: Some(1_779_991_200), beryl: Some(1_782_410_400), cobalt: Some(1_790_791_200), - denim: None, + denim: Some(1_793_901_600), everest: None, zenith: None, }, @@ -1117,7 +1117,7 @@ mod runtime_tests { assert_eq!( BaseUpgrade::from_chain_and_timestamp(CHAIN_ID, u64::MAX), - Some(BaseUpgrade::Cobalt), + Some(BaseUpgrade::Denim), ); RuntimeUpgradeRegistry::clear_chain(CHAIN_ID); @@ -1135,10 +1135,7 @@ mod runtime_tests { let _guard = RUNTIME_REGISTRY_TEST_LOCK.lock(); RuntimeUpgradeRegistry::clear_chain(8453); RuntimeUpgradeRegistry::clear_chain(84532); - assert_eq!( - BaseUpgrade::from_chain_and_timestamp(8453, u64::MAX), - Some(BaseUpgrade::Cobalt) - ); + assert_eq!(BaseUpgrade::from_chain_and_timestamp(8453, u64::MAX), Some(BaseUpgrade::Denim)); assert_eq!( BaseUpgrade::from_chain_and_timestamp(84532, u64::MAX), Some(BaseUpgrade::Denim) diff --git a/crates/execution/chainspec/src/spec.rs b/crates/execution/chainspec/src/spec.rs index e4926db9c0..08ec062f46 100644 --- a/crates/execution/chainspec/src/spec.rs +++ b/crates/execution/chainspec/src/spec.rs @@ -1091,18 +1091,21 @@ mod tests { } #[test] - fn builtin_chain_specs_schedule_denim_only_on_sepolia() { - for spec in [BaseChainSpec::mainnet(), BaseChainSpec::devnet()] { - assert_eq!(spec.fork(BaseUpgrade::Denim), ForkCondition::Never); - assert!(!spec.is_fork_active_at_timestamp(BaseUpgrade::Denim, 0)); - assert!(!spec.is_fork_active_at_timestamp(BaseUpgrade::Denim, u64::MAX)); - } + fn builtin_chain_specs_schedule_denim_on_mainnet_and_sepolia() { + let devnet = BaseChainSpec::devnet(); + assert_eq!(devnet.fork(BaseUpgrade::Denim), ForkCondition::Never); + assert!(!devnet.is_fork_active_at_timestamp(BaseUpgrade::Denim, 0)); + assert!(!devnet.is_fork_active_at_timestamp(BaseUpgrade::Denim, u64::MAX)); - let sepolia = BaseChainSpec::sepolia(); - let denim_timestamp = ChainConfig::sepolia().denim_timestamp.unwrap(); - assert_eq!(sepolia.fork(BaseUpgrade::Denim), ForkCondition::Timestamp(denim_timestamp)); - assert!(!sepolia.is_fork_active_at_timestamp(BaseUpgrade::Denim, denim_timestamp - 1)); - assert!(sepolia.is_fork_active_at_timestamp(BaseUpgrade::Denim, denim_timestamp)); + for (spec, config) in [ + (BaseChainSpec::mainnet(), ChainConfig::mainnet()), + (BaseChainSpec::sepolia(), ChainConfig::sepolia()), + ] { + let denim_timestamp = config.denim_timestamp.unwrap(); + assert_eq!(spec.fork(BaseUpgrade::Denim), ForkCondition::Timestamp(denim_timestamp)); + assert!(!spec.is_fork_active_at_timestamp(BaseUpgrade::Denim, denim_timestamp - 1)); + assert!(spec.is_fork_active_at_timestamp(BaseUpgrade::Denim, denim_timestamp)); + } } #[test] @@ -1375,7 +1378,7 @@ mod tests { fn latest_base_mainnet_fork_id() { let base_mainnet_spec = BaseChainSpec::mainnet(); assert_eq!( - base_mainnet_spec.hardfork_fork_id(BaseUpgrade::Cobalt).unwrap(), + base_mainnet_spec.hardfork_fork_id(BaseUpgrade::Denim).unwrap(), base_mainnet_spec.latest_fork_id() ) } @@ -1385,7 +1388,7 @@ mod tests { let base_mainnet_spec = BaseChainSpec::mainnet(); let base_mainnet = BaseChainSpecBuilder::base_mainnet().build(); assert_eq!( - base_mainnet_spec.hardfork_fork_id(BaseUpgrade::Cobalt).unwrap(), + base_mainnet_spec.hardfork_fork_id(BaseUpgrade::Denim).unwrap(), base_mainnet.latest_fork_id() ) } From d0838a296534d5fb77e13608951a1e96d191ceb4 Mon Sep 17 00:00:00 2001 From: refcell Date: Tue, 20 Oct 2026 17:52:42 -0400 Subject: [PATCH 08/18] fix(systems): keep two-second snapshot continuation off Denim (#6127) Mainnet now schedules Denim, so the two-second snapshot cadence inherited it and switched the local schedule to 200ms blocks. Clear Denim on both the rollup config and the chain spec for the two-second cadence, and reject snapshot heads that are already past Denim. --- etc/systems/src/l2/snapshot_stack.rs | 60 +++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/etc/systems/src/l2/snapshot_stack.rs b/etc/systems/src/l2/snapshot_stack.rs index e1b953b5c8..d52f0d6b77 100644 --- a/etc/systems/src/l2/snapshot_stack.rs +++ b/etc/systems/src/l2/snapshot_stack.rs @@ -261,6 +261,9 @@ impl SnapshotL2Stack { } /// Anchors the deterministic local block schedule at the first post-snapshot block. + /// + /// The two-second cadence keeps the legacy schedule, so it clears any configured Denim + /// activation and rejects snapshot heads that are already past Denim. pub fn anchored_rollup_config( mut config: RollupConfig, boundary_number: u64, @@ -270,6 +273,12 @@ impl SnapshotL2Stack { let first_block_number = boundary_number .checked_add(1) .ok_or_else(|| eyre::eyre!("snapshot boundary block number overflow"))?; + if block_interval == DevnetBlockInterval::TwoSeconds { + ensure!( + !config.is_denim_active(config.l2_block_timestamp(boundary_number)), + "two-second snapshot continuation requires a pre-Denim snapshot head" + ); + } let blocks_since_genesis = first_block_number .checked_sub(config.genesis.l2.number) .ok_or_else(|| eyre::eyre!("snapshot boundary predates configured L2 genesis"))?; @@ -279,8 +288,13 @@ impl SnapshotL2Stack { config.genesis.l2_time = first_block_timestamp .checked_sub(legacy_elapsed) .ok_or_else(|| eyre::eyre!("snapshot is too far ahead to anchor locally"))?; - if block_interval == DevnetBlockInterval::TwoHundredMilliseconds { - config.set_upgrade_activation_timestamp(BaseUpgrade::Denim, first_block_timestamp); + match block_interval { + DevnetBlockInterval::TwoSeconds => { + config.clear_upgrade_activation_timestamp(BaseUpgrade::Denim); + } + DevnetBlockInterval::TwoHundredMilliseconds => { + config.set_upgrade_activation_timestamp(BaseUpgrade::Denim, first_block_timestamp); + } } let (planned_timestamp, planned_millis) = config.l2_block_timestamp_parts(first_block_number); @@ -292,15 +306,21 @@ impl SnapshotL2Stack { } /// Builds the execution chain specification for a local snapshot schedule. + /// + /// Denim activates at the anchor for the 200ms cadence and never for the two-second cadence, + /// matching [`Self::anchored_rollup_config`]. pub fn chain_spec( mut chain_spec: BaseChainSpec, first_block_timestamp: u64, block_interval: DevnetBlockInterval, ) -> BaseChainSpec { - if block_interval == DevnetBlockInterval::TwoHundredMilliseconds { - chain_spec - .set_fork(BaseUpgrade::Denim, ForkCondition::Timestamp(first_block_timestamp)); - } + let denim = match block_interval { + DevnetBlockInterval::TwoSeconds => ForkCondition::Never, + DevnetBlockInterval::TwoHundredMilliseconds => { + ForkCondition::Timestamp(first_block_timestamp) + } + }; + chain_spec.set_fork(BaseUpgrade::Denim, denim); chain_spec } @@ -491,6 +511,34 @@ mod tests { assert_eq!(config.l2_block_timestamp_parts(30_000_001), (2_000_000_000, 0)); assert_eq!(config.l2_block_timestamp_parts(30_000_002), (2_000_000_002, 0)); + assert!(!config.is_denim_active(u64::MAX)); + } + + #[test] + fn two_second_cl_and_el_never_activate_denim() { + let chain_spec = SnapshotL2Stack::chain_spec( + BaseChainSpec::mainnet(), + 2_000_000_000, + DevnetBlockInterval::TwoSeconds, + ); + + assert_eq!(chain_spec.fork(BaseUpgrade::Denim), ForkCondition::Never); + } + + #[test] + fn two_second_schedule_rejects_denim_snapshot_head() { + let mut canonical = ChainConfig::mainnet().rollup_config(); + canonical.set_upgrade_activation_timestamp(BaseUpgrade::Denim, canonical.genesis.l2_time); + + let error = SnapshotL2Stack::anchored_rollup_config( + canonical, + 30_000_000, + 2_000_000_000, + DevnetBlockInterval::TwoSeconds, + ) + .unwrap_err(); + + assert!(error.to_string().contains("pre-Denim snapshot head")); } #[test] From da801cf9f6e30bbf6b41f91041ab375ae04224f8 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Tue, 20 Oct 2026 15:49:53 -0700 Subject: [PATCH 09/18] chore(common): point Zeronet at the Zeronet bootnode (#5974) (#6135) (cherry picked from commit 32fb57fd018d9394fddf74dd3b7495bf031f2b67) --- crates/common/chains/src/config.rs | 9 +++------ crates/consensus/peers/src/nodes.rs | 6 +++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/common/chains/src/config.rs b/crates/common/chains/src/config.rs index fc17e1d231..7e65ce0f86 100644 --- a/crates/common/chains/src/config.rs +++ b/crates/common/chains/src/config.rs @@ -700,14 +700,11 @@ const ZERONET: ChainConfig = ChainConfig { bootnodes: Bootnodes { execution: &[ - "enode://cd4528698249ad8b36fa7b1cad75aa5683ad355e6f0776629eaff1d83cfbb575062330d711efefbfa0d531c86969c2daf9a88fb28cddbbad216f46ac367981eb@44.198.4.118:30301", - "enode://cd4528698249ad8b36fa7b1cad75aa5683ad355e6f0776629eaff1d83cfbb575062330d711efefbfa0d531c86969c2daf9a88fb28cddbbad216f46ac367981eb@44.198.4.118:9200", - "enode://ea188fb5482ff8eb372956d674ecb6d09cbd42e6874121957a47b2ad252f54953c49866d2dcabcfc272fcc63e163a67b097fe4354283e56ddf077fc017b2a127@52.2.11.95:30301", - "enode://ea188fb5482ff8eb372956d674ecb6d09cbd42e6874121957a47b2ad252f54953c49866d2dcabcfc272fcc63e163a67b097fe4354283e56ddf077fc017b2a127@52.2.11.95:9200", + "enode://695c5367f2934114e567a3b4ea77a629ff1c9fdfa0290afbe4c79de9ebd8e1448763c4fc4ce203f5e4bce05f293b756e190ae73609bff858377d79fc7905b0d8@54.90.107.224:30301", + "enode://695c5367f2934114e567a3b4ea77a629ff1c9fdfa0290afbe4c79de9ebd8e1448763c4fc4ce203f5e4bce05f293b756e190ae73609bff858377d79fc7905b0d8@54.90.107.224:9200", ], consensus: &[ - "enr:-J-4QDS5Z5P4BoDbOlLGOcdXjcv2Nc5_PgP28lIxP4lKU6qYR-m10c8rHdcHk0DdmTvZpndoSpuK__688dmX-tlOsNKGAZ22NI20gmlkgnY0gmlwhCzGBHaHb3BzdGFja4WA-80FAIlzZWNwMjU2azGhA4Qs8_ZWeMdUNldNdjnAxd018gjWofqKoW4_pr0qzvTtg3RjcIIkBoN1ZHCCJAY", - "enr:-J-4QKgMF6zAv7u_75LTXLJKgLtEn4HcI8gaqsDAl78nfw7VQE-EN6dUZCZW4_CI42MAOWUCinrV8rP5hbBu3aje-u-GAZ22LUBogmlkgnY0gmlwhDQCC1-Hb3BzdGFja4WA-80FAIlzZWNwMjU2azGhArwjzoKlEKQiEXtuZ0qT23Wy_3IeEXbAJo7VKDO2Yovig3RjcIIkBoN1ZHCCJAY", + "enr:-Ji4QLr5n2VbXQohRywhyi2_efPpMcKzKL7DtllVNgkn_fDuGszrobGsTjM9WX7TxVqbW6YX8pxEx7lmvHprVc3HX8wBgmlkgnY0gmlwhDZaa-CHb3BzdGFja4Tgyy4AiXNlY3AyNTZrMaEDG1GmDUlW2vWzc7QQ_QOgPFG1Gfn-sCC8FQTef-GdCo2DdGNwgiQGg3VkcIIkBg", ], }, diff --git a/crates/consensus/peers/src/nodes.rs b/crates/consensus/peers/src/nodes.rs index 8a02c611ae..42e07bc023 100644 --- a/crates/consensus/peers/src/nodes.rs +++ b/crates/consensus/peers/src/nodes.rs @@ -66,8 +66,8 @@ mod tests { assert_eq!(ChainConfig::mainnet().bootnodes.consensus.len(), 5); assert_eq!(ChainConfig::sepolia().bootnodes.execution.len(), 4); assert_eq!(ChainConfig::sepolia().bootnodes.consensus.len(), 2); - assert_eq!(ChainConfig::zeronet().bootnodes.execution.len(), 4); - assert_eq!(ChainConfig::zeronet().bootnodes.consensus.len(), 2); + assert_eq!(ChainConfig::zeronet().bootnodes.execution.len(), 2); + assert_eq!(ChainConfig::zeronet().bootnodes.consensus.len(), 1); } #[test] @@ -89,7 +89,7 @@ mod tests { assert_eq!(testnet.len(), 2); let zeronet = BootNodes::from_chain_id(ChainConfig::zeronet().chain_id); - assert_eq!(zeronet.len(), 2); + assert_eq!(zeronet.len(), 1); let unknown = BootNodes::from_chain_id(0); assert!(unknown.is_empty()); From 837a52cb6755b3771a052611f4c53a10fe7b07d2 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Tue, 20 Oct 2026 16:29:18 -0700 Subject: [PATCH 10/18] fix(builder): record per-block inclusion metrics once under payload-builder cutover (#6132) (#6136) The cutover router sends every build to the unselected builder with no_tx_pool set. The native builder recorded inclusion histograms for those shadow builds, doubling txs_included_per_block and its sibling gas and fee-revenue histograms. Record them only for pool builds, matching the flashblocks builder. (cherry picked from commit e603fbf29e7007f8da53d3c42d2581453d9b622a) Co-authored-by: Toshi --- crates/execution/payload/src/builder.rs | 30 ++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/execution/payload/src/builder.rs b/crates/execution/payload/src/builder.rs index bb79d9727c..7946d740f1 100644 --- a/crates/execution/payload/src/builder.rs +++ b/crates/execution/payload/src/builder.rs @@ -494,7 +494,12 @@ impl Builder<'_, Txs> { Some(executed), block_access_list.map(|bal| alloy_rlp::encode(bal).into()), ); - BuilderMetrics::record_inclusion(&info.inclusion); + // `no_tx_pool` builds replay consensus input; the payload-builder cutover also sends one + // to the unselected builder for every block. Only the builder that composed the block + // records its inclusion, matching flashblocks. + if !no_tx_pool { + BuilderMetrics::record_inclusion(&info.inclusion); + } if no_tx_pool || ctx.is_denim_active() { // if `no_tx_pool` is set only transactions from the payload attributes will be included @@ -1654,6 +1659,29 @@ mod tests { assert_eq!(build_empty_payload(state_root_handle()), B256::repeat_byte(0x42)); } + #[cfg(feature = "metrics")] + #[test] + fn only_pool_builds_record_per_block_inclusion() { + let recorder = metrics_exporter_prometheus::PrometheusBuilder::new().build_recorder(); + let handle = recorder.handle(); + + metrics::with_local_recorder(&recorder, || { + build_empty_payload(state_root_handle()); + assert!(!handle.render().contains("txs_included_per_block")); + + build_pool_payload( + pool_payload_context(DENIM_TIMESTAMP), + TestParkableTransactions::new(vec![pool_transaction(0)]), + ); + }); + + let rendered = handle.render(); + assert!( + rendered.contains(r#"base_builder_txs_included_per_block_count{flow="standard"} 1"#) + ); + assert!(rendered.contains(r#"base_builder_txs_included_per_block_sum{flow="standard"} 1"#)); + } + const DENIM_TIMESTAMP: u64 = 1; fn pool_payload_context(timestamp: u64) -> BasePayloadBuilderCtx { From b5b73422bed792211a56b22c07f835b4f2bb31f2 Mon Sep 17 00:00:00 2001 From: Haardik Date: Wed, 21 Oct 2026 10:28:17 -0400 Subject: [PATCH 11/18] perf(txpool): validate forwarded transaction batches together and insert them in one builder call (#5816) (#6142) * perf(txpool): validate transaction batches in one job with one state provider BaseTransactionPool::add_transactions validated and admitted each transaction separately, so a batch paid one validation-task round trip and one latest-state provider per transaction. Validate the protocol transactions of a batch with a single validate_transactions call that shares one state provider, then admit each in submission order. Batches containing EIP-8130 sidecar transactions keep the per-transaction path. * test(txpool): cover batch replacements, mixed origins and sidecar fallback Also qualify the batch outcome comment: outcomes match per-transaction adds unless the canonical head or the pool changes during the batch. * perf(txpool): insert forwarded transaction batches in one builder call Add base_insertValidatedTransactions, which admits up to 1024 validated transactions as one pool batch through add_transactions_with_origins and returns the single-call result for each, in order. The builder then validates a forwarded batch against one state provider instead of one per transaction. Forwarders use it behind --tx-forwarding-batched-insert, which defaults to off; with it off they send JSON-RPC batches of single calls as before. Roll out in two steps: deploy builders and sequencers that serve the method, then enable the flag on mempool nodes. With the flag on, batches over the limit or with requests lacking a batched form keep using single calls. --------- (cherry picked from commit 508a4a20906c5834cd0dc57250fbb2d0dc3a8c3f) Co-authored-by: Brian Bland Co-authored-by: Toshi --- crates/builder/core/src/shadow_validity.rs | 22 +- crates/execution/cli/src/standard_node.rs | 12 + crates/execution/tx-forwarding/Cargo.toml | 2 +- crates/execution/tx-forwarding/README.md | 7 +- crates/execution/tx-forwarding/src/config.rs | 13 ++ .../tx-forwarding/src/forwarder/config.rs | 3 + .../tx-forwarding/src/forwarder/request.rs | 14 ++ .../tx-forwarding/src/forwarder/task.rs | 164 +++++++++++++- .../execution/txpool/PROPAGATION_PIPELINE.md | 1 + crates/execution/txpool/src/builder/mod.rs | 2 +- crates/execution/txpool/src/builder/rpc.rs | 138 ++++++++++-- crates/execution/txpool/src/lib.rs | 4 +- crates/execution/txpool/src/pool.rs | 209 ++++++++++++++++-- crates/execution/txpool/src/validator.rs | 15 ++ docs/guides/P2P.md | 3 +- etc/docs-index.toml | 6 +- 16 files changed, 567 insertions(+), 48 deletions(-) diff --git a/crates/builder/core/src/shadow_validity.rs b/crates/builder/core/src/shadow_validity.rs index cabedd95f2..2a3dd0757e 100644 --- a/crates/builder/core/src/shadow_validity.rs +++ b/crates/builder/core/src/shadow_validity.rs @@ -8,7 +8,7 @@ use base_execution_txpool::{ BasePooledTransaction, BuilderApiImpl, BuilderApiServer, InsertMetering, TransactionValidity, ValidatedTransaction, ValidityOperator, ValidityPredicate, ValiditySignatureMode, }; -use jsonrpsee::core::RpcResult; +use jsonrpsee::{core::RpcResult, types::ErrorObjectOwned}; use reth_transaction_pool::TransactionPool; use crate::{BuilderApiExtensionConfig, BuilderMetrics, SharedMeteringProvider}; @@ -155,11 +155,27 @@ where &self, mut tx: ValidatedTransaction, ) -> RpcResult<()> { - let outcome = self.config.inject(&mut tx); + self.inject(&mut tx); + self.inner.insert_validated_transaction(tx).await + } + + async fn insert_validated_transactions( + &self, + mut txs: Vec>, + ) -> RpcResult>> { + // Reject oversized batches before recording injection metrics for them. + BuilderApiImpl::::check_batch_size(txs.len())?; + txs.iter_mut().for_each(|tx| self.inject(tx)); + self.inner.insert_validated_transactions(txs).await + } +} + +impl

ShadowValidityBuilderApi

{ + fn inject(&self, tx: &mut ValidatedTransaction) { + let outcome = self.config.inject(tx); if self.config.is_enabled() { BuilderMetrics::shadow_validity_injection_total(outcome.label()).increment(1); } - self.inner.insert_validated_transaction(tx).await } } diff --git a/crates/execution/cli/src/standard_node.rs b/crates/execution/cli/src/standard_node.rs index b113018302..024a1f6006 100644 --- a/crates/execution/cli/src/standard_node.rs +++ b/crates/execution/cli/src/standard_node.rs @@ -434,6 +434,13 @@ pub struct RpcStandardNodeArgs { )] pub tx_forwarding_queue_capacity: Option, + /// Send each forwarding batch as one `base_insertValidatedTransactions` call. + /// + /// The builder admits the batch as one pool batch instead of one call per transaction. Every + /// builder in `--builder-rpc-urls` must serve that method before this is enabled. + #[arg(long = "tx-forwarding-batched-insert", requires = "enable_tx_forwarding")] + pub tx_forwarding_batched_insert: bool, + /// Run `meter_bundle` on the mempool node before inserting into the forwarding pool. /// /// Has no effect until the sim-worker path is wired; keep off in production. @@ -586,6 +593,7 @@ impl From<&StandardNodeArgs> for TxForwardingConfig { .with_max_rps(args.rpc.tx_forwarding_max_rps) .with_fifo_percent(args.rpc.tx_forwarding_fifo_percent) .with_queue_capacity(args.rpc.tx_forwarding_queue_capacity) + .with_batched_insert(args.rpc.tx_forwarding_batched_insert) .with_inline_simulation(args.rpc.enable_inline_simulation) .with_inline_simulation_workers(args.rpc.inline_simulation_workers) .with_inline_simulation_queue_capacity(args.rpc.inline_simulation_queue_capacity) @@ -1017,6 +1025,7 @@ mod tests { tx_forwarding_max_rps: DEFAULT_MAX_RPS, tx_forwarding_fifo_percent: DEFAULT_FIFO_PERCENT, tx_forwarding_queue_capacity: None, + tx_forwarding_batched_insert: false, enable_inline_simulation: false, inline_simulation_workers: DEFAULT_INLINE_SIMULATION_WORKERS, inline_simulation_queue_capacity: DEFAULT_INLINE_SIMULATION_QUEUE_CAPACITY, @@ -1291,14 +1300,17 @@ mod tests { "35", "--tx-forwarding-queue-capacity", "64", + "--tx-forwarding-batched-insert", ]) .args, ); assert_eq!(defaults.fifo_percent, DEFAULT_FIFO_PERCENT); assert_eq!(defaults.queue_capacity, None); + assert!(!defaults.batched_insert); assert_eq!(configured.fifo_percent, 35); assert_eq!(configured.queue_capacity, Some(64)); + assert!(configured.batched_insert); } #[test] diff --git a/crates/execution/tx-forwarding/Cargo.toml b/crates/execution/tx-forwarding/Cargo.toml index bd3e828bba..d334cf77c4 100644 --- a/crates/execution/tx-forwarding/Cargo.toml +++ b/crates/execution/tx-forwarding/Cargo.toml @@ -32,9 +32,9 @@ serde.workspace = true futures.workspace = true tracing.workspace = true thiserror.workspace = true -serde_json.workspace = true tokio-util.workspace = true metrics = { workspace = true, optional = true } +serde_json = { workspace = true, features = ["raw_value"] } tokio = { workspace = true, features = ["sync", "rt", "time"] } jsonrpsee = { workspace = true, features = ["http-client"] } diff --git a/crates/execution/tx-forwarding/README.md b/crates/execution/tx-forwarding/README.md index 612b463692..b0154c55f0 100644 --- a/crates/execution/tx-forwarding/README.md +++ b/crates/execution/tx-forwarding/README.md @@ -34,7 +34,11 @@ pool for every free slot, so a new transaction can wait that long before it is c A slow builder backpressures only its own reader. Transactions are marked as recently sent only after that builder's queue accepts them, so another destination cannot suppress their delivery. Each forwarder sends an isolated request immediately, but drains any other requests already waiting -for that destination into the same RPC batch up to the configured batch size. +for that destination into the same RPC batch up to the configured batch size. With +`--tx-forwarding-batched-insert`, a batch of inserts goes out as one +`base_insertValidatedTransactions` call, which the builder admits as one pool batch and answers +with a result per transaction. It is off by default: deploy builders that serve the method first, +then enable it on the forwarding nodes. ## Forwarding without the pool @@ -80,6 +84,7 @@ than a logged skip, so a caller never silently forwards to fewer destinations th | `--tx-forwarding-max-rps` | u32 | 200 | Maximum RPC requests per second per forwarder | | `--tx-forwarding-fifo-percent` | u8 | 20 | Percentage of picks taken oldest-first; the rest by highest tip (100 = pure FIFO) | | `--tx-forwarding-queue-capacity` | usize | 2x batch size | Per-builder queue capacity | +| `--tx-forwarding-batched-insert` | bool | false | Send each batch as one `base_insertValidatedTransactions` call; every builder must serve it | ## Usage diff --git a/crates/execution/tx-forwarding/src/config.rs b/crates/execution/tx-forwarding/src/config.rs index dc519503df..e5d00c8818 100644 --- a/crates/execution/tx-forwarding/src/config.rs +++ b/crates/execution/tx-forwarding/src/config.rs @@ -45,6 +45,11 @@ pub struct TxForwardingConfig { /// priority. With no backlog every pending transaction is sent in the next batch, so the /// percentage only matters while a destination is behind. pub fifo_percent: u8, + /// Whether batches of inserts are sent as one `base_insertValidatedTransactions` call instead + /// of a JSON-RPC batch of `base_insertValidatedTransaction` calls. + /// + /// Every destination must serve the batched method before this is enabled. + pub batched_insert: bool, /// Per-destination queue capacity. [`None`] uses /// [`DEFAULT_QUEUE_CAPACITY_BATCHES`] batches. pub queue_capacity: Option, @@ -71,6 +76,7 @@ impl Default for TxForwardingConfig { max_batch_size: DEFAULT_MAX_BATCH_SIZE, max_rps: DEFAULT_MAX_RPS, fifo_percent: DEFAULT_FIFO_PERCENT, + batched_insert: false, queue_capacity: None, inline_simulation: false, inline_simulation_workers: DEFAULT_INLINE_SIMULATION_WORKERS, @@ -115,6 +121,12 @@ impl TxForwardingConfig { self } + /// Enables or disables sending batches of inserts as one batched builder call. + pub const fn with_batched_insert(mut self, enabled: bool) -> Self { + self.batched_insert = enabled; + self + } + /// Sets the per-destination queue capacity. pub const fn with_queue_capacity(mut self, capacity: Option) -> Self { self.queue_capacity = capacity; @@ -166,6 +178,7 @@ impl TxForwardingConfig { ForwarderConfig { max_batch_size: self.max_batch_size, max_rps: self.max_rps, + batched_insert: self.batched_insert, ..Default::default() } } diff --git a/crates/execution/tx-forwarding/src/forwarder/config.rs b/crates/execution/tx-forwarding/src/forwarder/config.rs index 2a653d8500..0d96ccc232 100644 --- a/crates/execution/tx-forwarding/src/forwarder/config.rs +++ b/crates/execution/tx-forwarding/src/forwarder/config.rs @@ -25,6 +25,8 @@ pub(crate) struct ForwarderConfig { pub(crate) retry_backoff: Duration, /// Per-request timeout for the HTTP client. pub(crate) request_timeout: Duration, + /// Send batches of inserts as one `base_insertValidatedTransactions` call. + pub(crate) batched_insert: bool, } impl ForwarderConfig { @@ -44,6 +46,7 @@ impl Default for ForwarderConfig { max_retries: 3, retry_backoff: Duration::from_millis(100), request_timeout: Duration::from_secs(1), + batched_insert: false, } } } diff --git a/crates/execution/tx-forwarding/src/forwarder/request.rs b/crates/execution/tx-forwarding/src/forwarder/request.rs index 75c51d572e..5620fa4290 100644 --- a/crates/execution/tx-forwarding/src/forwarder/request.rs +++ b/crates/execution/tx-forwarding/src/forwarder/request.rs @@ -4,6 +4,7 @@ use alloy_primitives::TxHash; use base_execution_txpool::{NoExtensions, ValidatedTransaction}; use jsonrpsee::core::params::ArrayParams; use serde::Serialize; +use serde_json::value::RawValue; /// One JSON-RPC call relayed to one forwarding destination. /// @@ -34,6 +35,15 @@ pub trait ForwardRequest: Send + Sync + 'static { /// /// [`None`] for a call not attributable to a single transaction. fn tx_hash(&self) -> Option; + + /// The serialized transaction, when this call is a `base_insertValidatedTransaction` that may + /// instead travel in one `base_insertValidatedTransactions` call with the rest of its batch. + /// + /// A batch uses that method only when every call in it returns [`Some`]; the default [`None`] + /// keeps the call in a JSON-RPC batch of individual calls. + fn batched_insert(&self) -> Option, serde_json::Error>> { + None + } } /// A `base_insertValidatedTransaction` call. @@ -66,6 +76,10 @@ where fn tx_hash(&self) -> Option { Some(self.tx_hash) } + + fn batched_insert(&self) -> Option, serde_json::Error>> { + Some(serde_json::value::to_raw_value(&self.transaction)) + } } #[cfg(test)] diff --git a/crates/execution/tx-forwarding/src/forwarder/task.rs b/crates/execution/tx-forwarding/src/forwarder/task.rs index be9f54b226..6bdeda6253 100644 --- a/crates/execution/tx-forwarding/src/forwarder/task.rs +++ b/crates/execution/tx-forwarding/src/forwarder/task.rs @@ -1,6 +1,7 @@ use std::{collections::VecDeque, sync::Arc, time::Instant}; use alloy_primitives::TxHash; +use base_execution_txpool::MAX_VALIDATED_INSERT_BATCH; use base_observability_events::{ TransactionEventProducer, TransactionEventType, transaction_event, }; @@ -8,16 +9,20 @@ use jsonrpsee::{ core::{ ClientError, client::{BatchResponse, ClientT}, - params::BatchRequestBuilder, + params::{ArrayParams, BatchRequestBuilder}, }, http_client::HttpClient, + types::{ErrorObject, ErrorObjectOwned}, }; -use serde_json::{Map, json}; +use serde_json::{Map, json, value::RawValue}; use tokio::{sync::mpsc, time}; use tracing::{debug, error, info, trace}; use super::{config::ForwarderConfig, metrics::ForwarderMetrics, request::ForwardRequest}; +/// Builder method that inserts a whole batch of validated transactions in one call. +const INSERT_BATCH_METHOD: &str = "base_insertValidatedTransactions"; + /// Sliding window rate limiter that tracks request timestamps. /// /// Maintains a bounded deque of send timestamps within a 1-second window. @@ -257,7 +262,7 @@ impl DestinationForwarder { ); } } - let result = self.send_batch(&batch).await; + let result = self.send(&batch).await; match result { Ok(response) => { @@ -353,6 +358,56 @@ impl DestinationForwarder { } } + /// Sends `batch` and returns one result per request, in order. + /// + /// With `batched_insert` on, a batch made only of inserts, and no larger than + /// [`MAX_VALIDATED_INSERT_BATCH`], goes out as one [`INSERT_BATCH_METHOD`] call, which the + /// builder admits as one pool batch. Everything else goes out as a JSON-RPC batch of single + /// calls. + async fn send(&self, batch: &[R]) -> Result>, ClientError> { + if self.config.batched_insert + && batch.len() <= MAX_VALIDATED_INSERT_BATCH + && let Some(transactions) = + batch.iter().map(ForwardRequest::batched_insert).collect::>>() + { + return self.send_insert_batch(transactions, batch.len()).await; + } + let response = self.send_batch(batch).await?; + Ok(response.into_iter().map(|result| result.map_err(ErrorObject::into_owned)).collect()) + } + + async fn send_insert_batch( + &self, + transactions: Vec, serde_json::Error>>, + expected: usize, + ) -> Result>, ClientError> { + let transactions = transactions + .into_iter() + .collect::, _>>() + .map_err(ClientError::ParseError)?; + let mut params = ArrayParams::new(); + params.insert(transactions).map_err(ClientError::ParseError)?; + // Each entry is `null` or the error `base_insertValidatedTransaction` would have returned. + let entries: Vec>> = + self.client.request(INSERT_BATCH_METHOD, params).await?; + if entries.len() != expected { + return Err(ClientError::Custom(format!( + "{INSERT_BATCH_METHOD} returned {} results for {expected} transactions", + entries.len() + ))); + } + entries + .into_iter() + .map(|entry| { + entry.map_or(Ok(Ok(())), |raw| { + serde_json::from_str::>(raw.get()) + .map(|error| Err(error.into_owned())) + .map_err(ClientError::ParseError) + }) + }) + .collect() + } + async fn send_batch(&self, batch: &[R]) -> Result, ClientError> { let mut request = BatchRequestBuilder::new(); for item in batch { @@ -413,9 +468,7 @@ mod tests { use alloy_primitives::{Address, B256, Bytes}; use base_execution_txpool::{NoExtensions, ValidatedTransaction}; - use jsonrpsee::{ - RpcModule, core::params::ArrayParams, http_client::HttpClientBuilder, server::Server, - }; + use jsonrpsee::{RpcModule, http_client::HttpClientBuilder, server::Server}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -424,6 +477,8 @@ mod tests { *, }; + const INSERT_METHOD: &str = "base_insertValidatedTransaction"; + #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct TestExtensions { test_tag: String, @@ -488,6 +543,15 @@ mod tests { max_retries: 0, retry_backoff: Duration::ZERO, request_timeout: Duration::from_secs(1), + batched_insert: false, + }) + } + + /// [`config`] with `batched_insert` on. + fn batched(max_rps: u32, max_batch_size: usize) -> Arc { + Arc::new(ForwarderConfig { + batched_insert: true, + ..(*config(max_rps, max_batch_size)).clone() }) } @@ -512,6 +576,45 @@ mod tests { (url::Url::parse(&format!("http://{address}")).unwrap(), received, handle) } + /// A destination serving both insert methods. The batched method records each transaction + /// under its own name and rejects the one with nonce 1. + async fn batch_rpc_server() -> (url::Url, Calls, jsonrpsee::server::ServerHandle) { + let received: Calls = Arc::default(); + let mut module = RpcModule::new(Arc::clone(&received)); + module + .register_method(INSERT_METHOD, |params, received, _| { + let (argument,): (Value,) = params.parse()?; + received.lock().unwrap().push((INSERT_METHOD.to_string(), argument)); + Ok::<_, ErrorObjectOwned>(()) + }) + .unwrap(); + module + .register_method(INSERT_BATCH_METHOD, |params, received, _| { + let (transactions,): (Vec,) = params.parse()?; + let mut received = received.lock().unwrap(); + Ok::<_, ErrorObjectOwned>( + transactions + .into_iter() + .map(|tx| { + let rejected = tx["raw"] == "0x01"; + received.push((INSERT_BATCH_METHOD.to_string(), tx)); + rejected + .then(|| ErrorObjectOwned::owned(-32000, "rejected", None::<()>)) + }) + .collect::>(), + ) + }) + .unwrap(); + let server = Server::builder().build(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap(); + let address = server.local_addr().unwrap(); + let handle = server.start(module); + (url::Url::parse(&format!("http://{address}")).unwrap(), received, handle) + } + + fn methods(received: &Calls) -> Vec { + received.lock().unwrap().iter().map(|(method, _)| method.clone()).collect() + } + /// Params of every recorded call, dropping the method name. fn payloads(received: &Calls) -> Vec { received.lock().unwrap().iter().map(|(_, params)| params.clone()).collect() @@ -760,4 +863,53 @@ mod tests { ); assert_eq!(calls[1].1, json!(removed), "each entry keeps its own params"); } + + #[tokio::test] + async fn insert_batches_travel_in_one_batched_call_with_per_transaction_results() { + let (url, received, _server) = batch_rpc_server().await; + let (_sender, receiver) = mpsc::channel(1); + let forwarder = forwarder(url, receiver, batched(0, 100)); + let batch: Vec<_> = (0..3).map(transaction::).collect(); + + let results = forwarder.send(&batch).await.unwrap(); + + assert_eq!(methods(&received), [INSERT_BATCH_METHOD; 3]); + assert_eq!(nonces(&received), [0, 1, 2]); + assert!(results[0].is_ok() && results[2].is_ok()); + assert_eq!(results[1].as_ref().unwrap_err().message(), "rejected"); + } + + /// With `batched_insert` off, inserts go out as a JSON-RPC batch of single calls even to a + /// destination that serves the batched method. + #[tokio::test] + async fn batched_insert_off_sends_single_calls() { + let (url, received, _server) = batch_rpc_server().await; + let (_sender, receiver) = mpsc::channel(1); + let forwarder = forwarder(url, receiver, config(0, 100)); + let batch: Vec<_> = (0..3).map(transaction::).collect(); + + assert!(forwarder.send(&batch).await.unwrap().iter().all(Result::is_ok)); + assert_eq!(methods(&received), [INSERT_METHOD; 3]); + assert_eq!(nonces(&received), [0, 1, 2]); + } + + /// Batches the batched method cannot carry — over its limit, or holding requests without a + /// batched form — still go out as JSON-RPC batches of single calls. + #[tokio::test] + async fn batches_without_a_batched_form_use_single_calls() { + let (url, received, _server) = batch_rpc_server().await; + let (_sender, receiver) = mpsc::channel(1); + let oversized_forwarder = forwarder(url.clone(), receiver, batched(0, 0)); + let oversized: Vec<_> = + (0..=MAX_VALIDATED_INSERT_BATCH as u64).map(transaction::).collect(); + assert!(oversized_forwarder.send(&oversized).await.unwrap().iter().all(Result::is_ok)); + assert!(methods(&received).iter().all(|method| method == INSERT_METHOD)); + + received.lock().unwrap().clear(); + let (_sender, receiver) = mpsc::channel(1); + let mixed_forwarder = forwarder(url, receiver, batched(0, 0)); + let mixed = [TestRequest::Insert(Box::new(transaction::(0).transaction))]; + mixed_forwarder.send(&mixed).await.unwrap(); + assert_eq!(methods(&received), [INSERT_METHOD]); + } } diff --git a/crates/execution/txpool/PROPAGATION_PIPELINE.md b/crates/execution/txpool/PROPAGATION_PIPELINE.md index 32bb2ac49b..3952e36402 100644 --- a/crates/execution/txpool/PROPAGATION_PIPELINE.md +++ b/crates/execution/txpool/PROPAGATION_PIPELINE.md @@ -154,6 +154,7 @@ Wires consumer + forwarder into the node using the `BaseNodeExtension` pattern. | `--tx-forwarding-resend-after-ms` | u64 | 4000 | Resend-after window in ms (default: 2 blocks) | | `--tx-forwarding-batch-size` | usize | 100 | Forwarder batch size | | `--tx-forwarding-max-rps` | u32 | 200 | Maximum RPC requests per second per forwarder | +| `--tx-forwarding-batched-insert` | bool | false | Send each batch as one `base_insertValidatedTransactions` call | ### Extension Pattern diff --git a/crates/execution/txpool/src/builder/mod.rs b/crates/execution/txpool/src/builder/mod.rs index 13d892a23d..9674290d2b 100644 --- a/crates/execution/txpool/src/builder/mod.rs +++ b/crates/execution/txpool/src/builder/mod.rs @@ -1,7 +1,7 @@ //! Builder RPC API server and associated metrics. mod rpc; -pub use rpc::{BuilderApiImpl, BuilderApiServer, InsertMetering}; +pub use rpc::{BuilderApiImpl, BuilderApiServer, InsertMetering, MAX_VALIDATED_INSERT_BATCH}; mod metrics; pub use metrics::Metrics as BuilderApiMetrics; diff --git a/crates/execution/txpool/src/builder/rpc.rs b/crates/execution/txpool/src/builder/rpc.rs index 49a61e996a..bb6d475a3d 100644 --- a/crates/execution/txpool/src/builder/rpc.rs +++ b/crates/execution/txpool/src/builder/rpc.rs @@ -17,7 +17,9 @@ use jsonrpsee::{ proc_macros::rpc, types::{ErrorCode, ErrorObjectOwned}, }; -use reth_transaction_pool::{TransactionOrigin, TransactionPool}; +use reth_transaction_pool::{ + AddedTransactionOutcome, PoolResult, PoolTransaction, TransactionOrigin, TransactionPool, +}; use serde_json::{Map, json}; use tracing::debug; @@ -36,6 +38,12 @@ static BUILDER_HOST: LazyLock = LazyLock::new(|| { hostname::get().ok().and_then(|name| name.into_string().ok()).unwrap_or_default() }); +/// Most transactions one `base_insertValidatedTransactions` call may carry. +/// +/// Equal to the forwarder's cap on an unlimited batch size; forwarders send larger batches as +/// individual calls. +pub const MAX_VALIDATED_INSERT_BATCH: usize = 1024; + /// Writes inbound `insertValidatedTransaction` metering into the builder cache. pub trait InsertMetering: core::fmt::Debug + Send + Sync + 'static { /// Stores metering for `tx_hash`. @@ -53,9 +61,21 @@ pub trait BuilderApi { /// allowing the builder to skip signature verification. /// /// Returns an error if decoding or pool insertion fails. - /// Use JSON-RPC batch requests for efficient bulk submission. #[method(name = "insertValidatedTransaction")] async fn insert_validated_transaction(&self, tx: ValidatedTransaction) -> RpcResult<()>; + + /// Inserts up to [`MAX_VALIDATED_INSERT_BATCH`] pre-validated transactions in order, as one + /// pool batch. + /// + /// Returns one entry per transaction, in order: `null` where + /// `base_insertValidatedTransaction` would have succeeded, otherwise the error it would have + /// returned. Fails as a whole for an oversized batch, or when any entry does not deserialize + /// as a `ValidatedTransaction`. + #[method(name = "insertValidatedTransactions")] + async fn insert_validated_transactions( + &self, + txs: Vec>, + ) -> RpcResult>>; } /// Server implementation of [`BuilderApi`] backed by a transaction pool. @@ -136,10 +156,71 @@ where E: ValidatedTransactionExtensions, { async fn insert_validated_transaction(&self, tx: ValidatedTransaction) -> RpcResult<()> { - debug!( - sender = %tx.sender, - "rpc::insert_validated_transaction" - ); + debug!(sender = %tx.sender, "rpc::insert_validated_transaction"); + let (origin, pool_tx, metering) = self.prepare_insert(tx)?; + let (tx_hash, encoded_len) = (*pool_tx.hash(), pool_tx.encoded_length()); + let start = Instant::now(); + let result = self.pool.add_transaction(origin, pool_tx).await; + BuilderApiMetrics::insert_duration().record(start.elapsed().as_secs_f64()); + self.finish_insert(tx_hash, encoded_len, metering, result) + } + + async fn insert_validated_transactions( + &self, + txs: Vec>, + ) -> RpcResult>> { + debug!(txs = txs.len(), "rpc::insert_validated_transactions"); + Self::check_batch_size(txs.len())?; + let mut results = vec![None; txs.len()]; + let mut admitted = Vec::with_capacity(txs.len()); + let mut batch = Vec::with_capacity(txs.len()); + for (index, tx) in txs.into_iter().enumerate() { + match self.prepare_insert(tx) { + Ok((origin, pool_tx, metering)) => { + admitted.push((index, *pool_tx.hash(), pool_tx.encoded_length(), metering)); + batch.push((origin, pool_tx)); + } + Err(error) => results[index] = Some(error), + } + } + let start = Instant::now(); + let outcomes = self.pool.add_transactions_with_origins(batch).await; + // Each transaction waited for the whole pool batch, as a single insert waits for its own. + let elapsed = start.elapsed().as_secs_f64(); + for ((index, tx_hash, encoded_len, metering), result) in admitted.into_iter().zip(outcomes) + { + BuilderApiMetrics::insert_duration().record(elapsed); + results[index] = self.finish_insert(tx_hash, encoded_len, metering, result).err(); + } + Ok(results) + } +} + +impl BuilderApiImpl +where + E: ValidatedTransactionExtensions, +{ + /// Rejects a `base_insertValidatedTransactions` call carrying more than + /// [`MAX_VALIDATED_INSERT_BATCH`] transactions, before any of them is processed. + pub fn check_batch_size(len: usize) -> RpcResult<()> { + if len > MAX_VALIDATED_INSERT_BATCH { + return Err(ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + format!( + "batch of {len} transactions exceeds the limit of {MAX_VALIDATED_INSERT_BATCH}" + ), + None::<()>, + )); + } + Ok(()) + } + + /// Checks and decodes one wire transaction into the pool transaction and origin it is + /// inserted with, plus its metering for [`Self::finish_insert`]. + fn prepare_insert( + &self, + tx: ValidatedTransaction, + ) -> RpcResult<(TransactionOrigin, BasePooledTransaction, Option)> { let sender = tx.sender; let has_extensions = !tx.extensions.is_empty(); @@ -167,7 +248,6 @@ where None::<()>, ) })?; - let tx_hash = *consensus_tx.hash(); let encoded_len = tx.raw.len(); let recovered = Recovered::new_unchecked(consensus_tx, sender); @@ -204,14 +284,19 @@ where // Extension-bearing transactions remain private so normal P2P gossip // cannot propagate the raw transaction without its extension metadata. - let start = Instant::now(); - let result = if has_extensions { - self.pool.add_transaction(TransactionOrigin::Private, pool_tx).await - } else { - self.pool.add_external_transaction(pool_tx).await - }; - BuilderApiMetrics::insert_duration().record(start.elapsed().as_secs_f64()); + let origin = + if has_extensions { TransactionOrigin::Private } else { TransactionOrigin::External }; + Ok((origin, pool_tx, metering)) + } + /// Records the pool's verdict on one prepared transaction and maps it to the RPC result. + fn finish_insert( + &self, + tx_hash: TxHash, + encoded_len: usize, + metering: Option, + result: PoolResult, + ) -> RpcResult<()> { match result { Ok(_) => { if let Some(metering) = metering @@ -224,7 +309,7 @@ where tx_hash, Map::from_iter([("encoded_len".to_string(), json!(encoded_len))]), ); - debug!(sender = %sender, "inserted validated transaction"); + debug!(tx_hash = %tx_hash, "inserted validated transaction"); BuilderApiMetrics::txs_inserted().increment(1); Ok(()) } @@ -238,7 +323,7 @@ where ("error".to_string(), json!(e.to_string())), ]), ); - debug!(sender = %sender, error = %e, "pool rejected transaction"); + debug!(tx_hash = %tx_hash, error = %e, "pool rejected transaction"); BuilderApiMetrics::txs_rejected(PoolRejectionLabel::from_error(&e)).increment(1); Err(ErrorObjectOwned::owned( ErrorCode::InternalError.code(), @@ -313,6 +398,27 @@ mod tests { } } + #[tokio::test] + async fn batch_insert_rejects_more_than_the_batch_limit() { + let handler = BuilderApiImpl::new(NoopTransactionPool::::new()); + let tx = ValidatedTransaction { + sender: Address::ZERO, + raw: Bytes::from_static(&[0x02]), + metering: None, + extensions: NoExtensions::default(), + }; + + let error = handler + .insert_validated_transactions(vec![tx.clone(); MAX_VALIDATED_INSERT_BATCH + 1]) + .await + .unwrap_err(); + assert_eq!(error.code(), ErrorCode::InvalidParams.code()); + + let results = + handler.insert_validated_transactions(vec![tx; MAX_VALIDATED_INSERT_BATCH]).await; + assert_eq!(results.unwrap().len(), MAX_VALIDATED_INSERT_BATCH); + } + #[tokio::test] async fn signed_validity_builder_enforces_flag_and_preserves_ordinary_transactions() { let handler = BuilderApiImpl::<_, TransactionValidity>::with_extensions( diff --git a/crates/execution/txpool/src/lib.rs b/crates/execution/txpool/src/lib.rs index fbdb36fa16..0585840272 100644 --- a/crates/execution/txpool/src/lib.rs +++ b/crates/execution/txpool/src/lib.rs @@ -81,7 +81,9 @@ mod pool_error_label; pub use pool_error_label::PoolRejectionLabel; mod builder; -pub use builder::{BuilderApiImpl, BuilderApiMetrics, BuilderApiServer, InsertMetering}; +pub use builder::{ + BuilderApiImpl, BuilderApiMetrics, BuilderApiServer, InsertMetering, MAX_VALIDATED_INSERT_BATCH, +}; mod wire; pub use wire::{ diff --git a/crates/execution/txpool/src/pool.rs b/crates/execution/txpool/src/pool.rs index be2bc72a61..a983a4a105 100644 --- a/crates/execution/txpool/src/pool.rs +++ b/crates/execution/txpool/src/pool.rs @@ -1024,22 +1024,39 @@ where origin: TransactionOrigin, transactions: Vec, ) -> Vec> { - let mut results = Vec::with_capacity(transactions.len()); - for transaction in transactions { - results.push(self.add_transaction(origin, transaction).await); - } - results + self.add_transactions_with_origins( + transactions.into_iter().map(|transaction| (origin, transaction)).collect(), + ) + .await } async fn add_transactions_with_origins( &self, transactions: Vec<(TransactionOrigin, Self::Transaction)>, ) -> Vec> { - let mut results = Vec::with_capacity(transactions.len()); - for (origin, transaction) in transactions { - results.push(self.add_transaction(origin, transaction).await); - } - results + if transactions.iter().any(|(_, transaction)| self.is_sidecar_transaction(transaction)) { + let mut results = Vec::with_capacity(transactions.len()); + for (origin, transaction) in transactions { + results.push(self.add_transaction(origin, transaction).await); + } + return results; + } + // Protocol validation reads canonical state, never pool contents. Every + // transaction is validated against one snapshot taken when the batch + // job starts and admitted in order only after the whole batch is + // validated, so outcomes match per-transaction adds unless the + // canonical head or the pool changes concurrently in that window. + let keys: Vec<_> = transactions + .iter() + .map(|(origin, tx)| (*origin, *tx.hash(), tx.sender(), tx.nonce())) + .collect(); + let validated = self.validator().validate_transactions(transactions).await; + keys.into_iter() + .zip(validated) + .map(|((origin, hash, sender, nonce), validated)| { + self.add_validated_protocol_transaction(origin, hash, sender, nonce, validated) + }) + .collect() } fn transaction_event_listener(&self, tx_hash: TxHash) -> Option { @@ -1914,14 +1931,15 @@ mod tests { use reth_tasks::Runtime; use reth_transaction_pool::{ CanonicalStateUpdate, PoolConfig, PoolUpdateKind, PriceBumpConfig, TransactionOrigin, - blobstore::InMemoryBlobStore, identifier::TransactionId, + blobstore::InMemoryBlobStore, error::PoolErrorKind, identifier::TransactionId, validate::EthTransactionValidatorBuilder, }; use super::*; use crate::{ - BaseL1BlockInfo, BaseOrdering, BasePooledTransaction, LimitClass, ValidityOperator, - ValidityPredicate, WatchSet, + BaseL1BlockInfo, BaseOrdering, BasePooledTransaction, BuilderApiImpl, BuilderApiServer, + LimitClass, NoExtensions, ValidatedTransaction, ValidityOperator, ValidityPredicate, + WatchSet, }; fn test_chain_id() -> u64 { @@ -2190,12 +2208,21 @@ mod tests { } fn signed_1559(signer: &PrivateKeySigner, nonce: u64) -> BasePooledTransaction { + signed_1559_with_fees(signer, nonce, 1_000, 0) + } + + fn signed_1559_with_fees( + signer: &PrivateKeySigner, + nonce: u64, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, + ) -> BasePooledTransaction { let tx = TxEip1559 { chain_id: test_chain_id(), nonce, gas_limit: 50_000, - max_fee_per_gas: 1_000, - max_priority_fee_per_gas: 0, + max_fee_per_gas, + max_priority_fee_per_gas, to: TxKind::Call(Address::repeat_byte(0xEE)), value: U256::ZERO, access_list: Default::default(), @@ -2217,6 +2244,158 @@ mod tests { signed_8130(signer, nonce_key, nonce_sequence, expiry, max_fee_per_gas, 1_000_000) } + #[tokio::test] + async fn batch_add_matches_sequential_add() { + let (funded, unfunded) = (signer(), signer()); + let batch = [ + signed_1559(&funded, 0), + signed_1559(&funded, 1), + signed_1559(&funded, 0), + signed_1559(&funded, 3), + signed_1559(&unfunded, 0), + signed_1559(&funded, 2), + ]; + let batch = batch.map(|tx| (TransactionOrigin::Local, tx)).to_vec(); + let (batched, pool) = assert_batch_matches_sequential(batch, &[funded.address()]).await; + assert!( + matches!(batched[4], Err(ref e) if matches!(e.kind, PoolErrorKind::InvalidTransaction(_))) + ); + assert_eq!(pool.pool_size().pending, 4); + } + + #[tokio::test] + async fn batch_add_matches_sequential_add_for_in_batch_replacements() { + let sender = signer(); + let replacement = signed_1559_with_fees(&sender, 0, 2_000, 0); + let replacement_hash = *replacement.hash(); + // A 5% bump over the replacement is below the pool's 10% price bump. + let batch = [ + signed_1559_with_fees(&sender, 0, 1_000, 0), + replacement, + signed_1559_with_fees(&sender, 0, 2_100, 0), + ]; + let batch = batch.map(|tx| (TransactionOrigin::Local, tx)).to_vec(); + let (batched, pool) = assert_batch_matches_sequential(batch, &[sender.address()]).await; + assert!(batched[1].is_ok()); + assert!( + matches!(batched[2], Err(ref e) if matches!(e.kind, PoolErrorKind::ReplacementUnderpriced)) + ); + assert!(pool.get(&replacement_hash).is_some()); + } + + #[tokio::test] + async fn batch_add_matches_sequential_add_with_mixed_origins() { + let (first, second) = (signer(), signer()); + let batch = vec![ + (TransactionOrigin::Local, signed_1559(&first, 0)), + (TransactionOrigin::External, signed_1559(&second, 0)), + (TransactionOrigin::Private, signed_1559(&first, 1)), + ]; + let expected: Vec<_> = batch.iter().map(|(origin, tx)| (*tx.hash(), *origin)).collect(); + let (batched, pool) = + assert_batch_matches_sequential(batch, &[first.address(), second.address()]).await; + assert!(batched.iter().all(Result::is_ok)); + for (hash, origin) in expected { + assert_eq!(pool.get(&hash).expect("pooled").origin, origin); + } + } + + #[tokio::test] + async fn batch_with_a_sidecar_transaction_matches_sequential_add() { + let sender = signer(); + let sidecar = self_paid_eoa_8130(&sender, U256::from(2), 0, 0, 1_000); + let sidecar_hash = *sidecar.hash(); + let batch = vec![ + (TransactionOrigin::Local, signed_1559(&sender, 0)), + (TransactionOrigin::Local, sidecar), + (TransactionOrigin::Local, signed_1559(&sender, 1)), + ]; + let (batched, pool) = assert_batch_matches_sequential(batch, &[sender.address()]).await; + assert!(batched.iter().all(Result::is_ok)); + assert!(pool.nonce_pool.read().contains(&sidecar_hash)); + } + + /// Adds `batch` one transaction at a time to one fresh pool and as one + /// batch to another, both funding `funded`, and asserts the same outcome + /// per transaction and the same pool size. Returns the batched outcomes + /// and pool. + async fn assert_batch_matches_sequential( + batch: Vec<(TransactionOrigin, BasePooledTransaction)>, + funded: &[Address], + ) -> (Vec>, IntegrationPool) { + // `PoolErrorKind` is not `PartialEq`; compare errors by hash and message. + let comparable = |results: &[PoolResult]| { + results + .iter() + .map(|result| match result { + Ok(outcome) => Ok(outcome.clone()), + Err(error) => Err((error.hash, error.kind.to_string())), + }) + .collect::>() + }; + let (sequential_pool, client) = build_integration_pool(); + funded.iter().for_each(|account| fund(&client, *account)); + let mut sequential = Vec::new(); + for (origin, transaction) in batch.clone() { + sequential.push(sequential_pool.add_transaction(origin, transaction).await); + } + + let (batch_pool, client) = build_integration_pool(); + funded.iter().for_each(|account| fund(&client, *account)); + let batched = batch_pool.add_transactions_with_origins(batch).await; + + assert_eq!(comparable(&batched), comparable(&sequential)); + let size = |pool: &IntegrationPool| (pool.pool_size().pending, pool.pool_size().queued); + assert_eq!(size(&batch_pool), size(&sequential_pool)); + (batched, batch_pool) + } + + #[tokio::test] + async fn rpc_batch_insert_matches_single_inserts() { + let (funded, unfunded) = (signer(), signer()); + let wire = |tx: BasePooledTransaction| { + let recovered = tx.clone_into_consensus(); + ValidatedTransaction { + sender: recovered.signer(), + raw: recovered.inner().encoded_2718().into(), + metering: None, + extensions: NoExtensions::default(), + } + }; + let mut undecodable = wire(signed_1559(&funded, 4)); + undecodable.raw = Bytes::from_static(&[0x02, 0xff]); + let batch = vec![ + wire(signed_1559(&funded, 0)), + wire(signed_1559(&funded, 1)), + wire(signed_1559(&funded, 0)), + undecodable, + wire(signed_1559(&unfunded, 0)), + wire(signed_1559(&funded, 2)), + ]; + + let (single_pool, client) = build_integration_pool(); + fund(&client, funded.address()); + let single = BuilderApiImpl::new(single_pool.clone()); + let mut expected = Vec::new(); + for tx in batch.clone() { + expected.push(single.insert_validated_transaction(tx).await.err()); + } + + let (batch_pool, client) = build_integration_pool(); + fund(&client, funded.address()); + let batched = + BuilderApiImpl::new(batch_pool.clone()).insert_validated_transactions(batch).await; + + assert_eq!(batched.unwrap(), expected); + assert_eq!( + expected.iter().map(Option::is_none).collect::>(), + [true, true, false, false, false, true] + ); + let size = |pool: &IntegrationPool| (pool.pool_size().pending, pool.pool_size().queued); + assert_eq!(size(&batch_pool), size(&single_pool)); + assert_eq!(size(&batch_pool), (3, 0)); + } + #[tokio::test] async fn standard_transactions_are_not_eip8130_gated() { let (pool, client) = build_integration_pool(); diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index 4957be1848..007afbf2bd 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -2204,6 +2204,21 @@ where self.validate_one(origin, transaction).await } + /// Validates the batch in order against one state provider, as reth's + /// `EthTransactionValidator` does, instead of opening one per transaction. + async fn validate_transactions( + &self, + transactions: impl IntoIterator + + Send, + ) -> Vec> { + let mut state = None; + let mut outcomes = Vec::new(); + for (origin, transaction) in transactions { + outcomes.push(self.validate_one_with_state(origin, transaction, &mut state).await); + } + outcomes + } + fn on_new_head_block(&self, new_tip_block: &SealedBlock) { self.inner.on_new_head_block(new_tip_block); self.update_l1_block_info( diff --git a/docs/guides/P2P.md b/docs/guides/P2P.md index 9020dbdc0b..8eef3096f8 100644 --- a/docs/guides/P2P.md +++ b/docs/guides/P2P.md @@ -697,7 +697,8 @@ polls the transaction pool for new pending transactions and queues them through `tokio::mpsc` channel. The [`DestinationForwarder`](https://github.com/base/base/blob/main/crates/execution/tx-forwarding/src/forwarder/task.rs) receives queued transactions and forwards them via a custom JSON-RPC method -(`base_insertValidatedTransactions`) to configured builder endpoints. One forwarder task is spawned +(`base_insertValidatedTransaction`, or one `base_insertValidatedTransactions` call per batch with +`--tx-forwarding-batched-insert`) to configured builder endpoints. One forwarder task is spawned per builder URL, so multiple downstream builders can receive transactions simultaneously. This pipeline runs as background tasks on the node's task executor. diff --git a/etc/docs-index.toml b/etc/docs-index.toml index c7531a0e15..f22ebbcc17 100644 --- a/etc/docs-index.toml +++ b/etc/docs-index.toml @@ -408,7 +408,7 @@ digest = "559e8e9f95" ["crates/execution/tx-forwarding/README.md"] summary = "base-tx-forwarding: forwards mempool txs to builder RPCs via per-destination queues fed by FIFO and tip-priority lanes; dedup, batching, resend; spawn_requests for custom ForwardRequest; CLI flags." -digest = "b72a1536e1" +digest = "824cdc212c" ["crates/execution/txpool-rpc/README.md"] summary = "Txpool RPC admin/status APIs and validity submissions; shared staged signature modes, signature rejection metrics, predicate forwarding and sequencer proxy behavior." @@ -420,7 +420,7 @@ digest = "e58f6b44fb" ["crates/execution/txpool/PROPAGATION_PIPELINE.md"] summary = "Plan to forward mempool txs to builders via base_insertValidatedTransaction in 4 PRs (consumer, forwarder, builder endpoint, node integration). Only PR 1 is marked done. Names a crates/txpool/ path." -digest = "2e785bfe9d" +digest = "ce42872576" ["crates/execution/txpool/README.md"] summary = "Base txpool validation/ordering, builder wire extensions, staged EIP-712 predicate authorization, wallet signing contract, rejection metrics, and rollout/security limits." @@ -656,7 +656,7 @@ digest = "393c4961d6" ["docs/guides/P2P.md"] summary = "Explains the Base P2P stack for newcomers: consensus-layer discv5 and libp2p gossipsub versus execution-layer reth networking, how they interact via Engine API, key files, flags, and glossary." -digest = "2407f38ac8" +digest = "0a50fafb9b" ["docs/guides/RELEASE.md"] summary = "Release process: Start Release creates releases/vX.Y.Z, version sync PR, automatic RC tags and builds per commit, then Publish Release for the final tag, Docker images and binaries." From 39ff05220ef742a4a81e3bcd6c3079c8d9864717 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 22 Oct 2026 07:38:37 -0700 Subject: [PATCH 12/18] perf(txpool): compare tip-per-gas bids with exact cross-products (#5817) (#6140) `UnifiedTipPriority::cmp` cross-multiplied with `U256::saturating_mul`. When a product saturated (tips above 2^192 wei), distinct bids compared equal and equality was no longer transitive, so sorted and ordered collections could order the same bids differently depending on insertion history. Priority-fee bids (gas 1 on both sides) now compare tips directly, and other pairs compare exact 320-bit U256 x u64 products. A gas of 0 compares as 1. Ordering is unchanged for every pair whose product fits in 256 bits; only ordering among unpayable tips above 2^192 wei changes. (cherry picked from commit 792cb641918acdd72cb05390d5637a6c45379e66) Co-authored-by: Toshi --- crates/execution/txpool/src/ordering.rs | 109 +++++++++++++++++++++--- 1 file changed, 99 insertions(+), 10 deletions(-) diff --git a/crates/execution/txpool/src/ordering.rs b/crates/execution/txpool/src/ordering.rs index 4fecaac48c..9534bd9a11 100644 --- a/crates/execution/txpool/src/ordering.rs +++ b/crates/execution/txpool/src/ordering.rs @@ -11,7 +11,7 @@ use std::{ time::Instant, }; -use alloy_primitives::{TxHash, U256}; +use alloy_primitives::{TxHash, U64, U256, Uint}; use base_common_consensus::CoinbaseTip; use reth_transaction_pool::{PoolTransaction, Priority, TransactionOrdering, ValidPoolTransaction}; @@ -92,9 +92,9 @@ impl Default for BaseOrdering { /// `tip` and `gas = 1`. A statically decoded coinbase tip uses that amount as /// `tip` and the transaction `gas_limit` as `gas`. /// -/// Equality and ordering compare the bid by cross-multiplication, then fewer -/// predicates. Distinct `(tip, gas)` pairs that represent the same ratio compare -/// equal. +/// Equality and ordering compare the bid by exact cross-multiplication, then fewer +/// predicates, so the order is total for every `tip`. Distinct `(tip, gas)` pairs that +/// represent the same ratio compare equal. A `gas` of `0` compares as `1`. #[derive(Debug, Clone, Copy)] pub struct UnifiedTipPriority { /// Numerator of the tip-per-gas bid. @@ -138,11 +138,17 @@ impl PartialOrd for UnifiedTipPriority { impl Ord for UnifiedTipPriority { fn cmp(&self, other: &Self) -> Ordering { - // Saturate at U256::MAX so a pathological tip*gas product ranks as - // the highest bid instead of wrapping. - let left = self.tip.saturating_mul(U256::from(other.gas)); - let right = other.tip.saturating_mul(U256::from(self.gas)); - left.cmp(&right).then_with(|| self.predicates.cmp(&other.predicates)) + // Priority-fee bids (gas 1) compare tips directly. Otherwise a U256 tip times a + // u64 gas always fits in 320 bits, so the cross-products are exact and never + // saturate; saturation would make equality non-transitive. + let bid = if self.gas <= 1 && other.gas <= 1 { + self.tip.cmp(&other.tip) + } else { + let product = + |tip: U256, gas: u64| -> Uint<320, 5> { tip.widening_mul(U64::from(gas.max(1))) }; + product(self.tip, other.gas).cmp(&product(other.tip, self.gas)) + }; + bid.then_with(|| self.predicates.cmp(&other.predicates)) } } @@ -297,7 +303,7 @@ where #[cfg(test)] mod tests { use alloy_eips::eip2718::Encodable2718; - use alloy_primitives::{Bytes, U256}; + use alloy_primitives::{Bytes, U256, U512}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; use base_common_chains::ChainConfig; @@ -310,6 +316,7 @@ mod tests { use reth_transaction_pool::{ CoinbaseTipOrdering, PoolTransaction, TransactionOrdering, test_utils::TransactionBuilder, }; + use rstest::rstest; use super::*; use crate::{BasePooledTransaction, ValidityOperator, ValidityPredicate}; @@ -665,4 +672,86 @@ mod tests { let tx = eip8130_pooled(10, 0, 21_000, Some(U256::from(42_000))); assert_eq!(ordering.priority(&tx, 20), Priority::None); } + + /// Bids by struct literal, so `gas == 0` reaches `cmp` without `new`'s clamp. + fn bid(tip: U256, gas: u64) -> UnifiedTipPriority { + UnifiedTipPriority { tip, gas, predicates: Reverse(0) } + } + + /// `(2^64 - 1) * LARGEST_UNSATURATED = 2^256 - 1`: one more wei of tip overflows 256 bits + /// against `u64::MAX` gas, where saturating cross-products used to tie. + fn largest_unsaturated() -> U256 { + (U256::from(1) << 192) + (U256::from(1) << 128) + (U256::from(1) << 64) + U256::from(1) + } + + #[rstest] + #[case::gas_one_compares_tips(bid(U256::from(2), 1), bid(U256::from(1), 1), Ordering::Greater)] + #[case::gas_one_max_tips(bid(U256::MAX, 1), bid(U256::MAX - U256::from(1), 1), Ordering::Greater)] + #[case::gas_zero_as_one(bid(U256::from(5), 0), bid(U256::from(5), 1), Ordering::Equal)] + #[case::gas_zero_ratio(bid(U256::from(1), 0), bid(U256::from(2), 1), Ordering::Less)] + #[case::equal_ratio(bid(U256::from(3), 2), bid(U256::from(6), 4), Ordering::Equal)] + #[case::coinbase_tip_vs_priority_fee( + bid(U256::from(63_000), 21_000), + bid(U256::from(2), 1), + Ordering::Greater + )] + #[case::largest_unsaturated_product( + bid(largest_unsaturated(), 1), + bid(U256::MAX, u64::MAX), + Ordering::Equal + )] + #[case::saturating_product( + bid(largest_unsaturated() + U256::from(1), 1), + bid(U256::MAX, u64::MAX), + Ordering::Greater + )] + #[case::both_products_saturate( + bid(U256::MAX / U256::from(100_000) + U256::from(1), 200_000), + bid(U256::MAX / U256::from(200_000) + U256::from(1), 300_000), + Ordering::Greater + )] + fn compares_bids_exactly( + #[case] left: UnifiedTipPriority, + #[case] right: UnifiedTipPriority, + #[case] expected: Ordering, + ) { + assert_eq!(left.cmp(&right), expected); + assert_eq!(right.cmp(&left), expected.reverse()); + } + + /// Matching an exact rational oracle on every pair makes the order total (transitive and + /// antisymmetric) over these values, including products on both sides of 2^256. + #[test] + fn ordering_matches_exact_ratio_oracle() { + let exact = |l: &UnifiedTipPriority, r: &UnifiedTipPriority| { + let product = |tip: U256, gas: u64| U512::from(tip) * U512::from(gas.max(1)); + product(l.tip, r.gas) + .cmp(&product(r.tip, l.gas)) + .then_with(|| l.predicates.cmp(&r.predicates)) + }; + let tips = [ + U256::ZERO, + U256::from(1), + U256::from(3), + U256::from(6), + U256::from(u128::MAX), + U256::from(1) << 192, + largest_unsaturated(), + largest_unsaturated() + U256::from(1), + U256::MAX / U256::from(200_000) + U256::from(1), + U256::MAX / U256::from(100_000) + U256::from(1), + U256::MAX, + ]; + let gases = [0, 1, 2, 4, 21_000, 100_000, 200_000, 300_000, u64::MAX]; + let priorities: Vec<_> = tips + .iter() + .flat_map(|&tip| gases.iter().map(move |&gas| bid(tip, gas))) + .flat_map(|p| [p, UnifiedTipPriority { predicates: Reverse(1), ..p }]) + .collect(); + for l in &priorities { + for r in &priorities { + assert_eq!(l.cmp(r), exact(l, r), "{l:?} vs {r:?}"); + } + } + } } From f727751db5635affdca43a14f5ae5e2fe3b7ed79 Mon Sep 17 00:00:00 2001 From: Haardik Date: Thu, 22 Oct 2026 11:36:04 -0400 Subject: [PATCH 13/18] perf(txpool): store sequential lane with parked and ready transactions (#5717) (#6175) Lane invalidation compared every parked and ready entry by recomputing BestTransactionLane::for_transaction. Store the lane alongside each entry (and the source head) so invalidation compares a stored value and yielding no longer recomputes the lane already derived when the source head was filled. (cherry picked from commit 2a0ebfe0b590504e3b7e9710c8b85fcc4df7c9bf) Co-authored-by: Brian Bland Co-authored-by: Toshi --- crates/execution/txpool/src/parking.rs | 95 ++++++++++++++++---------- 1 file changed, 59 insertions(+), 36 deletions(-) diff --git a/crates/execution/txpool/src/parking.rs b/crates/execution/txpool/src/parking.rs index 1232f33f7e..84f0d50317 100644 --- a/crates/execution/txpool/src/parking.rs +++ b/crates/execution/txpool/src/parking.rs @@ -102,10 +102,10 @@ where inner: I, ordering: O, base_fee: u64, - source_head: Option>>, + source_head: Option<(Arc>, Option)>, lanes: HashMap>, - parked: HashMap>>, - ready: HashMap>>, + parked: HashMap>, Option)>, + ready: HashMap>, Option)>, ready_heap: BinaryHeap<(BestTransactionPriority, TxHash)>, } @@ -152,14 +152,18 @@ where BestTransactionPriority::new(&self.ordering, transaction, self.base_fee) } - /// Adds a transaction to the priority-ordered ready set. - pub fn push_ready(&mut self, transaction: Arc>) { + /// Adds a transaction and its sequential lane to the priority-ordered ready set. + pub fn push_ready( + &mut self, + transaction: Arc>, + lane: Option, + ) { let hash = *transaction.hash(); if self.ready.contains_key(&hash) { return; } let priority = self.priority(&transaction); - self.ready.insert(hash, transaction); + self.ready.insert(hash, (transaction, lane)); self.ready_heap.push((priority, hash)); } @@ -179,7 +183,7 @@ where Entry::Vacant(_) => return, }; if let Some(next) = next { - self.push_ready(next); + self.push_ready(next, Some(lane)); } } @@ -189,20 +193,11 @@ where /// head notifies `inner`, whose lane bookkeeping excludes its remaining descendants. pub fn invalidate_lane(&mut self, lane: BestTransactionLane) { self.lanes.insert(lane, BestTransactionLaneState::Invalid); - if self - .source_head - .as_ref() - .and_then(BestTransactionLane::for_transaction) - .is_some_and(|head_lane| head_lane == lane) - { + if self.source_head.as_ref().is_some_and(|(_, head_lane)| *head_lane == Some(lane)) { self.source_head = None; } - self.parked.retain(|_, transaction| { - BestTransactionLane::for_transaction(transaction) != Some(lane) - }); - self.ready.retain(|_, transaction| { - BestTransactionLane::for_transaction(transaction) != Some(lane) - }); + self.parked.retain(|_, (_, parked_lane)| *parked_lane != Some(lane)); + self.ready.retain(|_, (_, ready_lane)| *ready_lane != Some(lane)); self.ready_heap.retain(|(_, hash)| self.ready.contains_key(hash)); } @@ -218,7 +213,7 @@ where return; }; let Some(lane) = BestTransactionLane::for_transaction(&transaction) else { - self.source_head = Some(transaction); + self.source_head = Some((transaction, None)); return; }; match self.lanes.get_mut(&lane) { @@ -229,7 +224,7 @@ where } None => {} } - self.source_head = Some(transaction); + self.source_head = Some((transaction, Some(lane))); } } @@ -241,12 +236,14 @@ where self.ready_heap.peek().map(|(priority, _)| priority) } - /// Pops the highest-priority non-stale ready transaction. - pub fn pop_ready(&mut self) -> Option>> { + /// Pops the highest-priority non-stale ready transaction and its sequential lane. + pub fn pop_ready( + &mut self, + ) -> Option<(Arc>, Option)> { loop { let (_, hash) = self.ready_heap.pop()?; - if let Some(transaction) = self.ready.remove(&hash) { - return Some(transaction); + if let Some(entry) = self.ready.remove(&hash) { + return Some(entry); } } } @@ -255,8 +252,9 @@ where pub fn record_yielded( &mut self, transaction: Arc>, + lane: Option, ) -> Arc> { - if let Some(lane) = BestTransactionLane::for_transaction(&transaction) { + if let Some(lane) = lane { self.lanes .entry(lane) .or_insert_with(|| BestTransactionLaneState::Occupied(VecDeque::new())); @@ -277,7 +275,7 @@ where self.fill_source_head(); let ready_priority = self.ready_priority().cloned(); - let source_priority = self.source_head.as_ref().map(|source| self.priority(source)); + let source_priority = self.source_head.as_ref().map(|(source, _)| self.priority(source)); let take_ready = match (source_priority, ready_priority) { (Some(source), Some(ready)) => ready >= source, (None, Some(_)) => true, @@ -285,12 +283,12 @@ where (None, None) => return None, }; - let transaction = if take_ready { + let (transaction, lane) = if take_ready { self.pop_ready().expect("ready priority requires a ready transaction") } else { self.source_head.take().expect("source priority requires a source transaction") }; - Some(self.record_yielded(transaction)) + Some(self.record_yielded(transaction, lane)) } } @@ -323,15 +321,15 @@ where O: TransactionOrdering, { fn park(&mut self, transaction: &Arc>) { - let hash = *transaction.hash(); - self.parked.insert(hash, Arc::clone(transaction)); + let lane = BestTransactionLane::for_transaction(transaction); + self.parked.insert(*transaction.hash(), (Arc::clone(transaction), lane)); } fn promote(&mut self, transaction_hash: TxHash) -> bool { - let Some(transaction) = self.parked.remove(&transaction_hash) else { + let Some((transaction, lane)) = self.parked.remove(&transaction_hash) else { return false; }; - self.push_ready(transaction); + self.push_ready(transaction, lane); true } @@ -340,10 +338,10 @@ where transaction_hash: TxHash, kind: InvalidPoolTransactionError, ) -> bool { - let Some(transaction) = self.parked.remove(&transaction_hash) else { + let Some((transaction, lane)) = self.parked.remove(&transaction_hash) else { return false; }; - if let Some(lane) = BestTransactionLane::for_transaction(&transaction) { + if let Some(lane) = lane { self.invalidate_lane(lane); } self.inner.mark_invalid(&transaction, kind); @@ -553,10 +551,35 @@ mod tests { let inner = StaticBestTransactions::new(Vec::new()); let mut best = ParkedBestTransactions::new(inner, BaseOrdering::coinbase_tip(), 0); - best.push_ready(transaction); + best.push_ready(transaction, Some(lane)); best.invalidate_lane(lane); assert!(best.ready.is_empty()); assert!(best.ready_heap.is_empty()); } + + #[test] + fn invalidating_lane_discards_only_its_parked_transactions() { + let invalid_signer = PrivateKeySigner::random(); + let other_signer = PrivateKeySigner::random(); + let invalid_parked = transaction(&invalid_signer, U256::from(1), 0, 100); + let other_parked = transaction(&other_signer, U256::ZERO, 0, 90); + let invalid_lane = BestTransactionLane::for_transaction(&invalid_parked).unwrap(); + let inner = StaticBestTransactions::new(vec![ + Arc::clone(&invalid_parked), + Arc::clone(&other_parked), + ]); + let mut best = ParkedBestTransactions::new(inner, BaseOrdering::coinbase_tip(), 0); + + let first = best.next().unwrap(); + best.park(&first); + let second = best.next().unwrap(); + best.park(&second); + best.invalidate_lane(invalid_lane); + + assert!(!best.promote(*invalid_parked.hash())); + assert!(best.promote(*other_parked.hash())); + assert_eq!(best.next().unwrap().hash(), other_parked.hash()); + assert!(best.next().is_none()); + } } From 0511dd7a5d1195a403eb532bc8331973fb71654a Mon Sep 17 00:00:00 2001 From: Haardik Date: Thu, 22 Oct 2026 13:03:28 -0400 Subject: [PATCH 14/18] perf(txpool): invalidate sequential lanes lazily (#6179) invalidate_lane only marks the lane Invalid. Members of an invalid lane are dropped when promote, discard_parked, ready_priority, or pop_ready touches them, so a rejection no longer scans every parked and ready entry. (cherry picked from commit 80b926a27f4ee8f3f66ae6cfb72d50e0dfbf8811) Co-authored-by: Toshi --- crates/execution/txpool/src/parking.rs | 87 ++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/crates/execution/txpool/src/parking.rs b/crates/execution/txpool/src/parking.rs index 84f0d50317..dc90f1f656 100644 --- a/crates/execution/txpool/src/parking.rs +++ b/crates/execution/txpool/src/parking.rs @@ -187,18 +187,28 @@ where } } - /// Discards all locally tracked members of a terminally invalid lane. + /// Terminally invalidates a lane for the remainder of this iterator. /// /// Buffered descendants have already been consumed from `inner`. Invalidating the yielded lane /// head notifies `inner`, whose lane bookkeeping excludes its remaining descendants. + /// + /// Parked and ready members of the lane are not removed here: the build loop invalidates a + /// lane on every rejection, and scanning both maps each time costs O(parked + ready) per + /// rejection. [`BestTransactionLaneState::Invalid`] is terminal, so `promote`, `discard_parked`, + /// [`Self::ready_priority`], and [`Self::pop_ready`] drop those members when they next touch + /// them. pub fn invalidate_lane(&mut self, lane: BestTransactionLane) { self.lanes.insert(lane, BestTransactionLaneState::Invalid); if self.source_head.as_ref().is_some_and(|(_, head_lane)| *head_lane == Some(lane)) { self.source_head = None; } - self.parked.retain(|_, (_, parked_lane)| *parked_lane != Some(lane)); - self.ready.retain(|_, (_, ready_lane)| *ready_lane != Some(lane)); - self.ready_heap.retain(|(_, hash)| self.ready.contains_key(hash)); + } + + /// Returns whether `lane` was terminally invalidated during this iteration. + pub fn is_lane_invalid(&self, lane: Option) -> bool { + lane.is_some_and(|lane| { + matches!(self.lanes.get(&lane), Some(BestTransactionLaneState::Invalid)) + }) } /// Pulls through blocked descendants until the next source candidate is lane-eligible. @@ -228,21 +238,31 @@ where } } - /// Removes stale heap entries and returns the highest-priority ready key. + /// Removes stale and invalid-lane heap entries and returns the highest-priority ready key. pub fn ready_priority(&mut self) -> Option<&BestTransactionPriority> { - while self.ready_heap.peek().is_some_and(|(_, hash)| !self.ready.contains_key(hash)) { + while let Some(&(_, hash)) = self.ready_heap.peek() { + let live = self.ready.get(&hash).map(|(_, lane)| !self.is_lane_invalid(*lane)); + if live == Some(true) { + break; + } + if live == Some(false) { + self.ready.remove(&hash); + } self.ready_heap.pop(); } self.ready_heap.peek().map(|(priority, _)| priority) } - /// Pops the highest-priority non-stale ready transaction and its sequential lane. + /// Pops the highest-priority ready transaction and its sequential lane, skipping stale heap + /// entries and members of invalidated lanes. pub fn pop_ready( &mut self, ) -> Option<(Arc>, Option)> { loop { let (_, hash) = self.ready_heap.pop()?; - if let Some(entry) = self.ready.remove(&hash) { + if let Some(entry) = self.ready.remove(&hash) + && !self.is_lane_invalid(entry.1) + { return Some(entry); } } @@ -329,6 +349,9 @@ where let Some((transaction, lane)) = self.parked.remove(&transaction_hash) else { return false; }; + if self.is_lane_invalid(lane) { + return false; + } self.push_ready(transaction, lane); true } @@ -341,6 +364,9 @@ where let Some((transaction, lane)) = self.parked.remove(&transaction_hash) else { return false; }; + if self.is_lane_invalid(lane) { + return false; + } if let Some(lane) = lane { self.invalidate_lane(lane); } @@ -544,7 +570,7 @@ mod tests { } #[test] - fn invalidating_lane_removes_its_ready_heap_entries() { + fn invalidating_lane_excludes_its_ready_transactions() { let signer = PrivateKeySigner::random(); let transaction = transaction(&signer, U256::from(1), 0, 100); let lane = BestTransactionLane::for_transaction(&transaction).unwrap(); @@ -554,8 +580,47 @@ mod tests { best.push_ready(transaction, Some(lane)); best.invalidate_lane(lane); - assert!(best.ready.is_empty()); - assert!(best.ready_heap.is_empty()); + assert!(best.ready_priority().is_none()); + assert!(best.next().is_none()); + } + + #[test] + fn invalidated_ready_transaction_is_skipped_in_priority_order() { + let invalid_signer = PrivateKeySigner::random(); + let high_signer = PrivateKeySigner::random(); + let low_signer = PrivateKeySigner::random(); + let invalid = transaction(&invalid_signer, U256::ZERO, 0, 100); + let high = transaction(&high_signer, U256::ZERO, 0, 90); + let low = transaction(&low_signer, U256::ZERO, 0, 10); + let invalid_lane = BestTransactionLane::for_transaction(&invalid).unwrap(); + let inner = StaticBestTransactions::new(Vec::new()); + let mut best = ParkedBestTransactions::new(inner, BaseOrdering::coinbase_tip(), 0); + + for transaction in [&invalid, &high, &low] { + let lane = BestTransactionLane::for_transaction(transaction); + best.push_ready(Arc::clone(transaction), lane); + } + best.invalidate_lane(invalid_lane); + + assert_eq!(best.next().unwrap().hash(), high.hash()); + assert_eq!(best.next().unwrap().hash(), low.hash()); + assert!(best.next().is_none()); + } + + #[test] + fn discarding_parked_member_of_invalidated_lane_is_a_no_op() { + let signer = PrivateKeySigner::random(); + let parked = transaction(&signer, U256::from(1), 0, 100); + let lane = BestTransactionLane::for_transaction(&parked).unwrap(); + let inner = StaticBestTransactions::new(vec![Arc::clone(&parked)]); + let mut best = ParkedBestTransactions::new(inner, BaseOrdering::coinbase_tip(), 0); + + let yielded = best.next().unwrap(); + best.park(&yielded); + best.invalidate_lane(lane); + + assert!(!best.discard_parked(*parked.hash(), InvalidPoolTransactionError::Underpriced)); + assert!(!best.promote(*parked.hash())); } #[test] From 5476069f1eb74a56bccb56fdefd4b4bb0e3cda42 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 22 Oct 2026 10:25:43 -0700 Subject: [PATCH 15/18] perf(precompiles): borrow calldata in base_precompile! instead of copying (#6180) The wrapper copied the full call input into a new buffer before dispatch. Every dispatcher already takes `&[u8]`, so pass the borrowed input through. Gas charged, return data, and state are unchanged. Co-authored-by: Chris Hunter --- crates/common/precompiles/src/activation/precompile.rs | 2 +- crates/common/precompiles/src/b20_asset/precompile.rs | 2 +- crates/common/precompiles/src/b20_factory/precompile.rs | 2 +- crates/common/precompiles/src/b20_stablecoin/precompile.rs | 2 +- crates/common/precompiles/src/macros.rs | 2 +- crates/common/precompiles/src/nonce/precompile.rs | 2 +- crates/common/precompiles/src/policy/precompile.rs | 2 +- crates/common/precompiles/src/tx_context/precompile.rs | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/common/precompiles/src/activation/precompile.rs b/crates/common/precompiles/src/activation/precompile.rs index bade5b2c53..92142d79e1 100644 --- a/crates/common/precompiles/src/activation/precompile.rs +++ b/crates/common/precompiles/src/activation/precompile.rs @@ -71,7 +71,7 @@ impl ActivationRegistry { let observer = observer.clone(); ActivationRegistryStorage::new(ctx).dispatch_with_observer( ctx, - &calldata, + calldata, admin_config, upgrade, observer, diff --git a/crates/common/precompiles/src/b20_asset/precompile.rs b/crates/common/precompiles/src/b20_asset/precompile.rs index dca15d3629..794f407af1 100644 --- a/crates/common/precompiles/src/b20_asset/precompile.rs +++ b/crates/common/precompiles/src/b20_asset/precompile.rs @@ -48,7 +48,7 @@ impl B20AssetPrecompile { PolicyRegistryStorage::new(ctx), version, ) - .dispatch_with_observer(ctx, &calldata, upgrade, observer) + .dispatch_with_observer(ctx, calldata, upgrade, observer) } ) } diff --git a/crates/common/precompiles/src/b20_factory/precompile.rs b/crates/common/precompiles/src/b20_factory/precompile.rs index 265b5293c1..41ec5f446e 100644 --- a/crates/common/precompiles/src/b20_factory/precompile.rs +++ b/crates/common/precompiles/src/b20_factory/precompile.rs @@ -39,7 +39,7 @@ impl B20Factory { storage_features: storage_features, |ctx, calldata| { let observer = observer.clone(); - B20FactoryStorage::new(ctx).dispatch_with_observer(ctx, &calldata, upgrade, observer) + B20FactoryStorage::new(ctx).dispatch_with_observer(ctx, calldata, upgrade, observer) } ) } diff --git a/crates/common/precompiles/src/b20_stablecoin/precompile.rs b/crates/common/precompiles/src/b20_stablecoin/precompile.rs index 14994a07c6..5eb2db2680 100644 --- a/crates/common/precompiles/src/b20_stablecoin/precompile.rs +++ b/crates/common/precompiles/src/b20_stablecoin/precompile.rs @@ -47,7 +47,7 @@ impl B20StablecoinPrecompile { PolicyRegistryStorage::new(ctx), version, ) - .dispatch_with_observer(ctx, &calldata, upgrade, observer) + .dispatch_with_observer(ctx, calldata, upgrade, observer) } ) } diff --git a/crates/common/precompiles/src/macros.rs b/crates/common/precompiles/src/macros.rs index c7b181debe..1ccad4c72b 100644 --- a/crates/common/precompiles/src/macros.rs +++ b/crates/common/precompiles/src/macros.rs @@ -19,7 +19,7 @@ macro_rules! base_precompile { ); } - let $calldata: ::alloy_primitives::Bytes = input.data.to_vec().into(); + let $calldata: &[u8] = input.data; let mut provider = ::base_precompile_storage::EvmPrecompileStorageProvider::new_with_storage_features( input, ::revm::context_interface::cfg::GasParams::default(), diff --git a/crates/common/precompiles/src/nonce/precompile.rs b/crates/common/precompiles/src/nonce/precompile.rs index 86c7a25335..24972f1abc 100644 --- a/crates/common/precompiles/src/nonce/precompile.rs +++ b/crates/common/precompiles/src/nonce/precompile.rs @@ -32,7 +32,7 @@ impl NonceManager { base_precompile!( "NonceManager", storage_features: storage_features, - |ctx, calldata| NonceManagerStorage::new(ctx).dispatch(ctx, &calldata), + |ctx, calldata| NonceManagerStorage::new(ctx).dispatch(ctx, calldata), ) } } diff --git a/crates/common/precompiles/src/policy/precompile.rs b/crates/common/precompiles/src/policy/precompile.rs index 801a1acb5b..94549ab6eb 100644 --- a/crates/common/precompiles/src/policy/precompile.rs +++ b/crates/common/precompiles/src/policy/precompile.rs @@ -47,7 +47,7 @@ impl PolicyRegistryPrecompile { |ctx, calldata| { let observer = observer.clone(); PolicyRegistryStorage::new(ctx) - .dispatch_with_observer(ctx, &calldata, upgrade, observer) + .dispatch_with_observer(ctx, calldata, upgrade, observer) } ) } diff --git a/crates/common/precompiles/src/tx_context/precompile.rs b/crates/common/precompiles/src/tx_context/precompile.rs index 8a732718c6..c5f358d2d8 100644 --- a/crates/common/precompiles/src/tx_context/precompile.rs +++ b/crates/common/precompiles/src/tx_context/precompile.rs @@ -32,7 +32,7 @@ impl TxContext { base_precompile!( "TxContext", storage_features: storage_features, - |ctx, calldata| TxContextStorage::new(ctx).dispatch(ctx, &calldata), + |ctx, calldata| TxContextStorage::new(ctx).dispatch(ctx, calldata), ) } } From 2a7eddd391cf2607c909120b0d8bd93200210839 Mon Sep 17 00:00:00 2001 From: StreamingFast Chain Update Bot Date: Wed, 28 Oct 2026 21:51:57 +0000 Subject: [PATCH 16/18] Test the mainnet Denim Firehose block time Cargo.lock also picks up the 1.5.1 version of the Firehose crates. --- Cargo.lock | 9 +++++---- bin/node/Cargo.toml | 3 +++ bin/node/src/firehose.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cca6f7f96..8bbe7310b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4547,7 +4547,7 @@ dependencies = [ [[package]] name = "base-execution-firehose" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-evm", @@ -4804,7 +4804,7 @@ dependencies = [ [[package]] name = "base-firehose-flashblocks" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4849,7 +4849,7 @@ dependencies = [ [[package]] name = "base-firehose-prestate" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-genesis", "base-common-chains", @@ -4867,7 +4867,7 @@ dependencies = [ [[package]] name = "base-firehose-tests" -version = "1.5.0" +version = "1.5.1" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6211,6 +6211,7 @@ version = "1.5.1" dependencies = [ "base-cli-utils", "base-common-genesis", + "base-execution-chainspec", "base-execution-cli", "base-firehose-flashblocks", "base-node-runner", diff --git a/bin/node/Cargo.toml b/bin/node/Cargo.toml index 8926a8ebcb..22003828ac 100644 --- a/bin/node/Cargo.toml +++ b/bin/node/Cargo.toml @@ -45,6 +45,9 @@ tokio-stream.workspace = true # tracing tracing.workspace = true +[dev-dependencies] +base-execution-chainspec.workspace = true + [features] default = [ "asm-keccak", "jemalloc", "keccak-cache-global" ] asm-keccak = [ "base-execution-cli/asm-keccak" ] diff --git a/bin/node/src/firehose.rs b/bin/node/src/firehose.rs index 97303903fb..ace41cf053 100644 --- a/bin/node/src/firehose.rs +++ b/bin/node/src/firehose.rs @@ -208,6 +208,8 @@ impl FromExtensionConfig for FirehoseFlashblocksExtension { mod tests { use std::num::NonZeroU64; + use base_execution_chainspec::BaseChainSpec; + use super::*; #[test] @@ -235,4 +237,28 @@ mod tests { } assert_eq!(block_time(None, &block(6, 1010)), UNIX_EPOCH + Duration::from_secs(1010)); } + + #[test] + fn mainnet_block_time_switches_to_milliseconds_at_denim() { + // Denim is configured for 1_793_901_600 and activates on the first 2-second slot at or + // after it: block 53_556_127, at 1_793_901_601. + let schedule = BaseChainSpec::mainnet() + .denim_timestamp_schedule() + .expect("mainnet has a block time") + .expect("mainnet schedules Denim"); + let block = |number, time| BlockData { number, time, ..Default::default() }; + + for (number, time, want_ms) in [ + (53_556_126, 1_793_901_599, 1_793_901_599_000), + (53_556_127, 1_793_901_601, 1_793_901_601_000), + (53_556_128, 1_793_901_601, 1_793_901_601_200), + (53_556_132, 1_793_901_602, 1_793_901_602_000), + ] { + assert_eq!( + block_time(Some(&schedule), &block(number, time)), + UNIX_EPOCH + Duration::from_millis(want_ms), + "block {number}" + ); + } + } } From 9a942ebcfda7e9021f93f2cc5911cc19323fbf47 Mon Sep 17 00:00:00 2001 From: StreamingFast Chain Update Bot Date: Wed, 28 Oct 2026 21:51:57 +0000 Subject: [PATCH 17/18] Review network activations for Base v1.5.1 --- ACTIVATIONS.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ACTIVATIONS.md b/ACTIVATIONS.md index ca7bf1493b..efa57776e1 100644 --- a/ACTIVATIONS.md +++ b/ACTIVATIONS.md @@ -7,7 +7,7 @@ and refresh the statuses when an activation is announced. Denim and Everest are contract-backed: an L1 upgrade signal (`crates/utilities/upgrade-signal`) can schedule them on a network with no new node release. Zenith can only be set in genesis. -Statuses checked 2026-10-16 against Base `v1.5.0` (`crates/common/chains/src/config.rs`). +Statuses checked 2026-10-28 against Base `v1.5.1` (`crates/common/chains/src/config.rs`). | Upgrade | Base mainnet | Base Sepolia | Effect on the Firehose block | |---------|--------------|--------------|------------------------------| @@ -15,7 +15,7 @@ Statuses checked 2026-10-16 against Base `v1.5.0` (`crates/common/chains/src/con | Azul | Active 2026-05-28 18:00 UTC | Active 2026-04-20 18:00 UTC | Already in the stream. | | Beryl | Active 2026-06-25 18:00 UTC | Active 2026-06-18 18:00 UTC | Already in the stream. | | Cobalt | Active 2026-09-30 18:00 UTC | Active 2026-09-23 18:00 UTC | B-20 V2 precompiles. Covered by `etc/systems/tests/firehose_cobalt_b20.rs`. | -| Denim | Not scheduled | 2026-10-21 18:00 UTC (`1_792_605_600`) | See [Denim](#denim). | +| Denim | Scheduled 2026-11-05 18:00 UTC (`1_793_901_600`) from `v1.5.1`; first Denim block is 53_556_127 at `1_793_901_601` | Active 2026-10-21 18:00 UTC (`1_792_605_600`) | See [Denim](#denim). **Mainnet nodes must run `v1.5.1-fh3.1` or later before activation.** | | Everest | Not scheduled | Not scheduled | See [Everest](#everest). Traced from `v1.5.0-fh3.1-2` as `TRX_TYPE_ACCOUNT_ABSTRACTION`. **Earlier versions stop on the first EIP-8130 transaction.** | | Zenith | Never (genesis only) | Never (genesis only) | Gates the EIP-8130 keystore (account configuration changes, non-k1 authenticators). Unreachable on real networks. Traced from `v1.5.0-fh3.1-2`: create and config change entries in `account_changes`, non-k1 sender authentication in `sender_auth`. | @@ -39,6 +39,14 @@ Statuses checked 2026-10-16 against Base `v1.5.0` (`crates/common/chains/src/con - B-20 precompiles switch to V3 (new policy rules). Same storage and event paths as V2. - New post-execution checks (base time schedule) run before the Firehose block is printed on the live and pipeline paths. +- The FIRE BLOCK line time, which becomes the Firehose block time, has millisecond precision from + the activation block on, computed from the chain's Denim schedule (`bin/node/src/firehose.rs`). + RPC adds `timestampMs` and `blockTimestampMs` to blocks, transactions, receipts and logs; they + carry the same value, which the Firehose block has only as its block time. +- Flashblocks stop. From the activation timestamp the sequencer builds with the basic builder and + publishes no flashblocks (`crates/builder/multiplex/src/router.rs`), so a node started with + `--firehose-flashblocks-url` emits no partial blocks after the last pre-Denim block. Full blocks + are unaffected and come every 200 ms. ## Everest From 8a2a8aa3f0a339b4e0b30883601fffaba26fcf24 Mon Sep 17 00:00:00 2001 From: StreamingFast Chain Update Bot Date: Wed, 28 Oct 2026 21:51:57 +0000 Subject: [PATCH 18/18] Add the v1.5.1-fh3.1 changelog entry --- CHANGELOG.sf.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.sf.md b/CHANGELOG.sf.md index ac3df6f698..2d5a15ed98 100644 --- a/CHANGELOG.sf.md +++ b/CHANGELOG.sf.md @@ -1,3 +1,14 @@ +## v1.5.1-fh3.1 + +### Changed + +* Rebased on upstream Base `v1.5.1`. It schedules the Denim hardfork on Base mainnet at + 2026-11-05 18:00:00 UTC, so mainnet operators must run this release before then. + `streamingfast/reth` stays on `base-v2.5.2.8-fh3.1-2` and `firehose-tracer` on 5.7.0; the + Firehose block model is unchanged. +* From Denim on, Base publishes no flashblocks, so a node started with `--firehose-flashblocks-url` + emits no partial blocks after the activation; full blocks come every 200 ms instead. + ## v1.5.0-fh3.1-2 ### Added